admin 홈 리빌딩 + 폼팝업 전용 렌더러 + 테이블 선택 emit 강화
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
- /admin 홈을 단순 링크 카드 → Welcome/Stats/SystemStatus/회사활동/최근변경/빠른진입 구성의 관리자 대시보드로 교체 (mock 데이터, 실데이터 배선은 후속) - PopupTemplateRenderer 신규 — 팝업은 canvas 1:1 유지가 필요하므로 반응형 TemplateRenderer 와 경로 분리, absolute 좌표 + transform scale 로 창을 꽉 채움. form-popup 이 templateId 로 자체 fetch 해서 stale views(screenResolutions 누락) 문제 해소 - DashboardCard: 팝업 outer 크기를 canvas + 브라우저 크롬 보정치로 계산, 부모가 template 객체 직접 전달하던 것 제거 - TemplateRenderer: BlockRenderer export, formRow 기반 columnName/value/onChange/onRowSelect 등 바인딩 props 전달 강화 - TableComponent: 행 클릭/체크박스 선택 시 onRowSelect / onSelectedRowsChange emit, 헤더 전체 선택 동작 구현, 싱글/멀티 선택 분기 정리 - AppLayout: 모드 전환 연출 강화(burst ring + particles / 헤더 sweep / breadcrumb swap / glow flash), horizontal nav + 데스크톱에서 햄버거 자동 접힘, 헤더 도구군 래핑 구조 정리 - DashboardEmpty 삭제 → 공통 EmptyDashboard 로 통합 - dashboard.css: ud-htools stagger(오른쪽 → 왼쪽 슬라이드) 애니메이션 적용 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+554
-202
@@ -1,227 +1,579 @@
|
||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2, Bot } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
Database,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
PhoneOutgoing,
|
||||
Workflow,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
History,
|
||||
Boxes,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
/**
|
||||
* 관리자 메인 페이지
|
||||
* 관리자 홈 — Admin Dashboard
|
||||
*
|
||||
* 참고 시안: notes/gbpark/invion admin-home layout v1 (2026-04-23)
|
||||
* 구성:
|
||||
* 1) Welcome banner (인사 + 요약 + 액션)
|
||||
* 2) Top stats × 4 (큰 숫자)
|
||||
* 3) System status × 4 (아이콘 + 상태)
|
||||
* 4) 회사별 활동 + 최근 변경 (2:1 grid)
|
||||
* 5) 빠른 진입 × 4
|
||||
*
|
||||
* ※ 현재는 mock 데이터. 실데이터 배선은 후속 작업.
|
||||
*/
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuth();
|
||||
const [now, setNow] = useState<Date | null>(null);
|
||||
useEffect(() => {
|
||||
setNow(new Date());
|
||||
}, []);
|
||||
|
||||
const userName =
|
||||
(user as any)?.user_name ||
|
||||
(user as any)?.userNameEng ||
|
||||
"슈퍼 관리자";
|
||||
const userInitial = userName[0] || "김";
|
||||
|
||||
const greeting = (() => {
|
||||
const h = now?.getHours() ?? 10;
|
||||
if (h < 6) return "늦은 밤입니다";
|
||||
if (h < 12) return "좋은 아침입니다";
|
||||
if (h < 18) return "오후입니다";
|
||||
return "좋은 저녁입니다";
|
||||
})();
|
||||
|
||||
const dateStr = now
|
||||
? `${now.getFullYear()}년 ${now.getMonth() + 1}월 ${now.getDate()}일 ${
|
||||
["일", "월", "화", "수", "목", "금", "토"][now.getDay()]
|
||||
}요일`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||
|
||||
{/* 주요 관리 기능 */}
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">주요 관리 기능</h2>
|
||||
<p className="text-muted-foreground">시스템의 핵심 관리 기능들을 제공합니다</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/admin/userMng" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Users className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">사용자 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">사용자 계정 및 권한 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-full bg-background">
|
||||
<div className="mx-auto w-full max-w-[1680px] space-y-5 p-5">
|
||||
{/* ===== 1) Welcome banner ===== */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-2xl border border-border px-6 py-5"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(var(--v5-primary-rgb),0.12), rgba(var(--v5-cyan-rgb),0.08) 50%, transparent 90%)",
|
||||
boxShadow: "var(--v5-glow-sm)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
className="flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-2xl text-xl font-bold text-white"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgb(var(--v5-cyan-rgb)), rgb(var(--v5-primary-rgb)))",
|
||||
boxShadow: "var(--v5-glow-md)",
|
||||
}}
|
||||
>
|
||||
{userInitial}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* <div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Shield className="text-success h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">권한 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">메뉴 및 기능 권한 설정</p>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 text-[0.72rem] text-muted-foreground">
|
||||
<Sparkles
|
||||
size={11}
|
||||
style={{ color: "rgb(var(--v5-cyan-rgb))" }}
|
||||
/>
|
||||
<span className="font-medium">{greeting}</span>
|
||||
<span>·</span>
|
||||
<span>{dateStr}</span>
|
||||
</div>
|
||||
<h1 className="text-[1.35rem] font-bold text-foreground leading-tight truncate">
|
||||
환영합니다,{" "}
|
||||
<span style={{ color: "rgb(var(--v5-primary-rgb))" }}>
|
||||
{userName}
|
||||
</span>
|
||||
<span className="text-foreground"> 슈퍼 관리자님</span>
|
||||
</h1>
|
||||
<p className="text-[0.78rem] text-muted-foreground">
|
||||
오늘도 좋은 하루 되십시오. 탑실 (COMPANY_27) · 오늘{" "}
|
||||
<b className="text-foreground">1,248건</b>의 변경이 기록됐고,{" "}
|
||||
<b style={{ color: "rgb(var(--v5-pink-rgb))" }}>2건</b>의
|
||||
배치 오류가 주의를 기다리고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Settings className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">시스템 설정</h3>
|
||||
<p className="text-muted-foreground text-sm">기본 설정 및 환경 구성</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<AdminPill icon={<History size={12} />} label="전체 이력" />
|
||||
<AdminPill icon={<CalendarDays size={12} />} label="배치" />
|
||||
<AdminPill
|
||||
icon={<Boxes size={12} />}
|
||||
label="관리 화면"
|
||||
tone="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-warning/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<BarChart3 className="text-warning h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">통계 및 리포트</h3>
|
||||
<p className="text-muted-foreground text-sm">시스템 사용 현황 분석</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<Link href="/admin/screenMng" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Palette className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">화면관리</h3>
|
||||
<p className="text-muted-foreground text-sm">드래그앤드롭으로 화면 설계 및 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/aiAssistant" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Bot className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">AI 어시스턴트</h3>
|
||||
<p className="text-muted-foreground text-sm">AI 채팅 및 LLM 연동 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 표준 관리 섹션 */}
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">표준 관리</h2>
|
||||
<p className="text-muted-foreground">시스템 표준 및 컴포넌트를 통합 관리합니다</p>
|
||||
{/* ===== 2) Top stats × 4 ===== */}
|
||||
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="활성 사용자"
|
||||
value="148"
|
||||
caption="전체 172 · 이번달 +4"
|
||||
tone="primary"
|
||||
trend="+4"
|
||||
/>
|
||||
<StatCard
|
||||
label="회사 · 활성/전체"
|
||||
value="6"
|
||||
unit="/6"
|
||||
caption="최근 접속 2분 전 · 이번달 +1"
|
||||
tone="primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="오늘 audit"
|
||||
value="1,248"
|
||||
caption="이번주 8,412"
|
||||
tone="primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="배치 실패 (24h)"
|
||||
value="2"
|
||||
caption="성공 184 · 예정 12"
|
||||
tone="danger"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ===== 3) System status × 4 ===== */}
|
||||
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusCard
|
||||
icon={<Database size={14} />}
|
||||
title="PostgreSQL"
|
||||
status="green"
|
||||
headline="정상"
|
||||
caption="142.6 GB · QPS 218 · CPU 38%"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Clock size={14} />}
|
||||
title="배치 스케줄러"
|
||||
status="amber"
|
||||
headline="3 실행"
|
||||
caption="24h 성공 184 · 실패 2 · 예정 12"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<PhoneOutgoing size={14} />}
|
||||
title="외부 호출"
|
||||
status="green"
|
||||
headline="4,218"
|
||||
caption="오늘 · 실패율 0.4% · 평균 412ms"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Workflow size={14} />}
|
||||
title="워크플로우"
|
||||
status="green"
|
||||
headline="36 활성"
|
||||
caption="오늘 실행 318 · 실패 2"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ===== 4) 회사별 활동 + 최근 변경 ===== */}
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(260px,1fr)]">
|
||||
{/* 회사별 활동 */}
|
||||
<div
|
||||
className="rounded-xl border border-border p-4"
|
||||
style={{ background: "var(--v5-surface-solid)" }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[0.9rem] font-bold text-foreground">
|
||||
회사별 활동
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground">6개 회사</div>
|
||||
</div>
|
||||
<AdminPill
|
||||
icon={<ChevronRight size={12} />}
|
||||
label="전체"
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{COMPANY_ACTIVITY.map((r) => (
|
||||
<CompanyRow key={r.code} row={r} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* <Link href="/admin/standards" className="block h-full">
|
||||
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Database className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">웹타입 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">입력 컴포넌트 웹타입 표준 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/templates" className="block h-full">
|
||||
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Layout className="text-success h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">템플릿 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">화면 디자이너 템플릿 표준 관리</p>
|
||||
</div>
|
||||
{/* 최근 변경 */}
|
||||
<div
|
||||
className="rounded-xl border border-border p-4"
|
||||
style={{ background: "var(--v5-surface-solid)" }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[0.9rem] font-bold text-foreground">
|
||||
최근 변경
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground">오늘</div>
|
||||
</div>
|
||||
</Link> */}
|
||||
|
||||
<Link href="/admin/tableMng" className="block h-full">
|
||||
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Database className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">테이블 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">데이터베이스 테이블 및 웹타입 매핑</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* <Link href="/admin/components" className="block h-full">
|
||||
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Package className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">컴포넌트 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">화면 디자이너 컴포넌트 표준 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link> */}
|
||||
<AdminPill
|
||||
icon={<ChevronRight size={12} />}
|
||||
label="더보기"
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{RECENT_CHANGES.map((e, i) => (
|
||||
<ChangeItem key={i} item={e} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 빠른 액세스 */}
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">빠른 액세스</h2>
|
||||
<p className="text-muted-foreground">자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다</p>
|
||||
{/* ===== 5) 빠른 진입 ===== */}
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<div className="text-[0.9rem] font-bold text-foreground">
|
||||
빠른 진입
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground">
|
||||
자주 쓰는 관리 화면
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Link href="/admin/menu" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Layout className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">메뉴 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">시스템 메뉴 및 네비게이션 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/automaticMng/exconList" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Database className="text-success h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">외부 연결 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">외부 데이터베이스 연결 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/systemMng/commonCodeList" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Settings className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">공통 코드 관리</h3>
|
||||
<p className="text-muted-foreground text-sm">시스템 공통 코드 및 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<QuickLink
|
||||
href="/admin/userMng"
|
||||
icon={<Users size={16} />}
|
||||
label="사용자관리"
|
||||
/>
|
||||
<QuickLink
|
||||
href="/admin/menu"
|
||||
icon={<FileText size={16} />}
|
||||
label="메뉴관리"
|
||||
/>
|
||||
<QuickLink
|
||||
href="/admin/systemMng/roleMng"
|
||||
icon={<Shield size={16} />}
|
||||
label="권한관리"
|
||||
/>
|
||||
<QuickLink
|
||||
href="/admin/systemMng/companyMng"
|
||||
icon={<Building2 size={16} />}
|
||||
label="회사관리"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전역 파일 관리 */}
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">전역 파일 관리</h2>
|
||||
<p className="text-muted-foreground">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
||||
</div>
|
||||
<GlobalFileViewer />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
/* Sub components */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
|
||||
function AdminPill({
|
||||
icon,
|
||||
label,
|
||||
tone,
|
||||
reverse,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
tone?: "primary";
|
||||
reverse?: boolean;
|
||||
}) {
|
||||
const isPrimary = tone === "primary";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[0.72rem] font-semibold transition-all hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: isPrimary
|
||||
? "rgb(var(--v5-primary-rgb))"
|
||||
: "var(--v5-surface-solid)",
|
||||
borderColor: isPrimary
|
||||
? "rgb(var(--v5-primary-rgb))"
|
||||
: "var(--v5-border)",
|
||||
color: isPrimary ? "#fff" : "var(--foreground)",
|
||||
boxShadow: isPrimary ? "var(--v5-glow-sm)" : "none",
|
||||
}}
|
||||
>
|
||||
{!reverse && icon}
|
||||
<span>{label}</span>
|
||||
{reverse && icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
caption,
|
||||
tone,
|
||||
trend: _trend,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
caption: string;
|
||||
tone?: "primary" | "danger";
|
||||
trend?: string;
|
||||
}) {
|
||||
const color =
|
||||
tone === "danger"
|
||||
? "rgb(var(--v5-pink-rgb))"
|
||||
: "rgb(var(--v5-primary-rgb))";
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border p-4 transition-all hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: "var(--v5-surface-solid)",
|
||||
boxShadow: "0 0 0 1px transparent",
|
||||
}}
|
||||
>
|
||||
<div className="text-[0.8rem] font-semibold text-foreground leading-tight">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end gap-0.5 leading-none">
|
||||
<span
|
||||
className="text-[2.25rem] font-black"
|
||||
style={{ color, textShadow: `0 0 14px ${color}40` }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{unit && (
|
||||
<span
|
||||
className="pb-1 text-[1.1rem] font-bold text-muted-foreground"
|
||||
>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[0.68rem] text-muted-foreground">
|
||||
{caption}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
icon,
|
||||
title,
|
||||
status,
|
||||
headline,
|
||||
caption,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
status: "green" | "amber" | "red";
|
||||
headline: string;
|
||||
caption: string;
|
||||
}) {
|
||||
const dotColor = {
|
||||
green: "#22c55e",
|
||||
amber: "#f59e0b",
|
||||
red: "rgb(var(--v5-pink-rgb))",
|
||||
}[status];
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl border border-border p-4 transition-all hover:-translate-y-0.5"
|
||||
style={{ background: "var(--v5-surface-solid)" }}
|
||||
>
|
||||
<span
|
||||
className="absolute right-4 top-4 h-1.5 w-1.5 rounded-full"
|
||||
style={{ background: dotColor, boxShadow: `0 0 8px ${dotColor}` }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-[0.72rem] text-muted-foreground">
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md"
|
||||
style={{
|
||||
background: "rgba(var(--v5-primary-rgb), 0.1)",
|
||||
color: "rgb(var(--v5-primary-rgb))",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-[1.25rem] font-bold text-foreground">
|
||||
{headline}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[0.68rem] text-muted-foreground">
|
||||
{caption}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CompanyRowData = {
|
||||
code: string;
|
||||
name: string;
|
||||
users: number;
|
||||
menus: number;
|
||||
roles: number;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
const COMPANY_ACTIVITY: CompanyRowData[] = [
|
||||
{ code: "COMPANY_27", name: "탑실", users: 84, menus: 92, roles: 6, lastSeen: "2분 전" },
|
||||
{ code: "COMPANY_9", name: "이멕스02", users: 42, menus: 92, roles: 4, lastSeen: "12분 전" },
|
||||
{ code: "COMPANY_10", name: "큐엔씨", users: 16, menus: 48, roles: 3, lastSeen: "1시간 전" },
|
||||
{ code: "COMPANY_7", name: "탑실 R3", users: 4, menus: 38, roles: 2, lastSeen: "4시간 전" },
|
||||
{ code: "COMPANY_8", name: "실본드", users: 2, menus: 28, roles: 2, lastSeen: "어제" },
|
||||
];
|
||||
|
||||
function CompanyRow({ row }: { row: CompanyRowData }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_minmax(0,1fr)_auto_auto_auto_auto] items-center gap-4 rounded-lg px-2 py-2 text-[0.74rem] hover:bg-muted/40">
|
||||
<span className="font-mono text-[0.68rem] text-muted-foreground">
|
||||
{row.code}
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">{row.name}</span>
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{ color: "rgb(var(--v5-primary-rgb))" }}
|
||||
>
|
||||
{row.users}{" "}
|
||||
<span className="font-normal text-muted-foreground">명</span>
|
||||
</span>
|
||||
<span>
|
||||
{row.menus}{" "}
|
||||
<span className="text-muted-foreground">메뉴</span>
|
||||
</span>
|
||||
<span>
|
||||
{row.roles}{" "}
|
||||
<span className="text-muted-foreground">권한</span>
|
||||
</span>
|
||||
<span className="min-w-[56px] text-right text-[0.66rem] text-muted-foreground">
|
||||
{row.lastSeen}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChangeEvent = {
|
||||
dot: "green" | "amber" | "red";
|
||||
title: string;
|
||||
detail: string;
|
||||
timestamp: string;
|
||||
actor: string;
|
||||
};
|
||||
|
||||
const RECENT_CHANGES: ChangeEvent[] = [
|
||||
{
|
||||
dot: "green",
|
||||
title: "메뉴 추가",
|
||||
detail: "COMPANY_27 / 생산관리 / 품목정보",
|
||||
timestamp: "14:22",
|
||||
actor: "mhkim",
|
||||
},
|
||||
{
|
||||
dot: "green",
|
||||
title: "권한 변경",
|
||||
detail: "ROLE_PROD_MGR (+2 사용자)",
|
||||
timestamp: "14:18",
|
||||
actor: "mhkim",
|
||||
},
|
||||
{
|
||||
dot: "green",
|
||||
title: "공통코드 추가",
|
||||
detail: "ITEM_TYPE / RAW_ADD (첨가제)",
|
||||
timestamp: "14:05",
|
||||
actor: "wace",
|
||||
},
|
||||
{
|
||||
dot: "red",
|
||||
title: "배치 실패",
|
||||
detail: "BATCH_EXPORT_INVOICE (timeout 30s)",
|
||||
timestamp: "13:54",
|
||||
actor: "system",
|
||||
},
|
||||
{
|
||||
dot: "amber",
|
||||
title: "사용자 잠금해제",
|
||||
detail: "khlee (로그인 5회 실패)",
|
||||
timestamp: "13:48",
|
||||
actor: "mhkim",
|
||||
},
|
||||
{
|
||||
dot: "green",
|
||||
title: "메뉴 순서 변경",
|
||||
detail: "COMPANY_9 / 구매 (seq 3 → 1)",
|
||||
timestamp: "13:34",
|
||||
actor: "mhkim",
|
||||
},
|
||||
];
|
||||
|
||||
function ChangeItem({ item }: { item: ChangeEvent }) {
|
||||
const dotColor = {
|
||||
green: "#22c55e",
|
||||
amber: "#f59e0b",
|
||||
red: "rgb(var(--v5-pink-rgb))",
|
||||
}[item.dot];
|
||||
return (
|
||||
<li className="flex gap-2.5">
|
||||
<span
|
||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: dotColor, boxShadow: `0 0 6px ${dotColor}` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.78rem] font-semibold text-foreground">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground truncate">
|
||||
{item.detail}
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-muted-foreground/80 mt-0.5">
|
||||
{item.timestamp} · {item.actor}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLink({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex items-center justify-between rounded-xl border border-border px-4 py-3.5 transition-all hover:-translate-y-0.5 hover:border-[rgb(var(--v5-primary-rgb))]"
|
||||
style={{ background: "var(--v5-surface-solid)" }}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "rgba(var(--v5-primary-rgb), 0.12)",
|
||||
color: "rgb(var(--v5-primary-rgb))",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-[0.85rem] font-semibold text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,8 @@ import { X } from 'lucide-react';
|
||||
import { FcForm } from '@/components/fc';
|
||||
import { getTemplateInfo } from '@/lib/api/template';
|
||||
import { fcInsert, fcUpdate } from '@/lib/api/fcData';
|
||||
import {
|
||||
TemplateRenderer,
|
||||
type TemplateRenderContext,
|
||||
} from '@/components/dash/TemplateRenderer';
|
||||
import type { TemplateRenderContext } from '@/components/dash/TemplateRenderer';
|
||||
import { PopupTemplateRenderer } from '@/components/dash/PopupTemplateRenderer';
|
||||
import type { FieldConfig, Template } from '@/types/invyone-component';
|
||||
|
||||
export default function FormPopupPage() {
|
||||
@@ -54,7 +52,9 @@ function FormPopupContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 에서 부모가 넘겨준 초기 데이터 읽기 + 즉시 삭제 (1회성)
|
||||
// localStorage 에서 부모가 넘겨준 초기 데이터 읽기 + 즉시 삭제 (1회성).
|
||||
// 여기서는 initialRow / primaryTable / templateName 만 받고 template 은
|
||||
// 항상 templateId 로 fresh fetch 한다. (부모 캐시는 views 가 stale 일 수 있음)
|
||||
let seededName = '';
|
||||
let seededTable = '';
|
||||
try {
|
||||
@@ -80,6 +80,30 @@ function FormPopupContent() {
|
||||
|
||||
getTemplateInfo(templateId)
|
||||
.then((tpl) => {
|
||||
// 진단용 로그 — template.views 구조 / screenResolutions 존재 여부 확인
|
||||
/* eslint-disable no-console */
|
||||
const v = (tpl as any)?.views ?? (tpl as any)?.VIEWS;
|
||||
console.log('[form-popup fetch]', {
|
||||
hasTpl: !!tpl,
|
||||
keys: tpl ? Object.keys(tpl as any) : null,
|
||||
viewsType: typeof v,
|
||||
viewsIsString: typeof v === 'string',
|
||||
viewsPreview:
|
||||
typeof v === 'string' ? String(v).slice(0, 200) : undefined,
|
||||
createSR:
|
||||
typeof v === 'object'
|
||||
? (v as any)?.screenResolutions?.create
|
||||
: undefined,
|
||||
globalSR:
|
||||
typeof v === 'object'
|
||||
? (v as any)?.screenResolution
|
||||
: undefined,
|
||||
hasScreenResolutions:
|
||||
typeof v === 'object'
|
||||
? !!(v as any)?.screenResolutions
|
||||
: undefined,
|
||||
});
|
||||
/* eslint-enable no-console */
|
||||
if (tpl) {
|
||||
setTemplate(tpl as Template);
|
||||
if (Array.isArray((tpl as any).fields)) {
|
||||
@@ -152,13 +176,16 @@ function FormPopupContent() {
|
||||
return false;
|
||||
}, [template, mode]);
|
||||
|
||||
// canvas 크기 resolve / resizeTo / scale fit 은 모두 PopupTemplateRenderer
|
||||
// 내부에서 처리. 여기서는 데이터만 준비한다.
|
||||
|
||||
const context: TemplateRenderContext = useMemo(
|
||||
() => ({
|
||||
fields,
|
||||
data: [],
|
||||
loading: false,
|
||||
primaryTable,
|
||||
selectedRow: null,
|
||||
selectedRow: formRow ?? null,
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
@@ -184,7 +211,9 @@ function FormPopupContent() {
|
||||
],
|
||||
);
|
||||
|
||||
if (!loaded) return <div className="p-6 text-sm">로딩 중...</div>;
|
||||
// 부모가 localStorage 로 template 을 시드해주면 같은 useEffect 턴에 loaded=true
|
||||
// 되므로 로딩 플래시가 사실상 보이지 않는다. 폴백 API 호출 중에만 잠깐 빈 화면.
|
||||
if (!loaded) return null;
|
||||
if (error)
|
||||
return (
|
||||
<div className="p-6 text-sm text-destructive">⚠ {error}</div>
|
||||
@@ -211,20 +240,24 @@ function FormPopupContent() {
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{hasCustomView ? (
|
||||
<TemplateRenderer
|
||||
// 전용 렌더러가 canvas × scale 로 창을 꽉 채운다. 내부에서 scale/resize
|
||||
// 전부 처리하므로 여기는 뷰포트만 넘겨주면 됨.
|
||||
<PopupTemplateRenderer
|
||||
template={template}
|
||||
context={context}
|
||||
view={mode}
|
||||
/>
|
||||
) : (
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={formRow ?? {}}
|
||||
onSubmit={(row) => handleSubmit(row)}
|
||||
config={{ columns: 2 }}
|
||||
/>
|
||||
<div className="p-4 w-full h-full overflow-auto">
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={formRow ?? {}}
|
||||
onSubmit={(row) => handleSubmit(row)}
|
||||
config={{ columns: 2 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useControlMode } from "@/components/control/hooks/useControlMode";
|
||||
import { deleteDashboardCard } from "@/lib/api/dashMenu";
|
||||
import { toast } from "sonner";
|
||||
import { DashboardCard } from "./DashboardCard";
|
||||
import { DashboardEmpty } from "./DashboardEmpty";
|
||||
import { EmptyDashboard } from "@/components/layout/EmptyDashboard";
|
||||
|
||||
/**
|
||||
* AnimatedFab — enter/exit 애니메이션을 가진 플로팅 액션 바.
|
||||
@@ -604,7 +604,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
return (
|
||||
<div ref={canvasRef} className={canvasClassName} onMouseDown={controlActive ? undefined : handleMouseDown}>
|
||||
{cards.length === 0 ? (
|
||||
<DashboardEmpty dashboardName={dashboardName} onOpenLibrary={onOpenLibrary} />
|
||||
<EmptyDashboard />
|
||||
) : (
|
||||
cards.map((card) => {
|
||||
const id = card.card_id ?? card.CARD_ID;
|
||||
|
||||
@@ -24,9 +24,20 @@ function computePopupFeatures(
|
||||
viewsObj?.screen_resolutions?.[view] ??
|
||||
viewsObj?.screenResolution ??
|
||||
viewsObj?.screen_resolution;
|
||||
const width = Math.max(400, Math.round(Number(res?.width) || 900));
|
||||
const height = Math.max(400, Math.round(Number(res?.height) || 700));
|
||||
return `width=${width},height=${height},resizable=yes,scrollbars=yes`;
|
||||
const canvasW = Math.max(400, Math.round(Number(res?.width) || 900));
|
||||
const canvasH = Math.max(400, Math.round(Number(res?.height) || 700));
|
||||
// 팝업 창 width/height 는 outer(브라우저 크롬 포함). form-popup 페이지는
|
||||
// - 상단 헤더(타이틀 바): ~44px
|
||||
// - 내용 영역 padding: 좌우 0(제거됨), 상하 0
|
||||
// - 브라우저 top chrome(탭+주소창): ~90px
|
||||
// - 좌우 스크롤바/여유: ~32px
|
||||
// canvas 에 딱 맞게 열어서 가로 스크롤 방지. 부족분은 팝업 페이지에서
|
||||
// resizeTo 로 실측 보정.
|
||||
const chromeW = 32; /* 브라우저 좌우 border + scrollbar 여유 */
|
||||
const chromeH = 44 /* 헤더 */ + 90 /* 브라우저 top chrome */ + 16; /* 여유 */
|
||||
const outerW = canvasW + chromeW;
|
||||
const outerH = canvasH + chromeH;
|
||||
return `width=${outerW},height=${outerH},resizable=yes,scrollbars=yes`;
|
||||
}
|
||||
|
||||
/** 팝업 고유 key + opener name — 여러 팝업 동시 허용 */
|
||||
@@ -172,6 +183,11 @@ export function DashboardCard({
|
||||
}
|
||||
const key = newPopupKey();
|
||||
try {
|
||||
// initialRow / primaryTable / templateName 만 넘긴다.
|
||||
// template 객체 자체는 넘기지 않는다 — 부모 DashboardCard 가 들고 있는
|
||||
// template 은 경량/정규화 전일 수 있어 views.screenResolutions 가
|
||||
// 누락된 상태로 팝업에 흘러들어가는 stale 버그를 유발한다.
|
||||
// 팝업은 templateId 로 자기 자신이 재fetch 한다.
|
||||
localStorage.setItem(
|
||||
`form-popup:${key}`,
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface DashboardEmptyProps {
|
||||
dashboardName: string;
|
||||
onOpenLibrary: () => void;
|
||||
}
|
||||
|
||||
export function DashboardEmpty({ dashboardName, onOpenLibrary }: DashboardEmptyProps) {
|
||||
return (
|
||||
<div
|
||||
className="dash-empty"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpenLibrary}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpenLibrary();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="dash-empty-icon" style={{ color: 'var(--v5-primary)' }}>
|
||||
<Plus size={38} />
|
||||
</div>
|
||||
<div className="dash-empty-title">{dashboardName}</div>
|
||||
<div className="dash-empty-desc">
|
||||
이제 템플릿을 추가합니다. 이 영역을 클릭하거나 상단 <b>템플릿 추가</b> 버튼을 눌러주세요.
|
||||
</div>
|
||||
<button
|
||||
className="dash-empty-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenLibrary();
|
||||
}}
|
||||
>
|
||||
<Plus size={14} style={{ marginRight: 4, verticalAlign: '-2px' }} />
|
||||
템플릿 추가
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* PopupTemplateRenderer — form-popup 전용 템플릿 렌더러.
|
||||
*
|
||||
* TemplateRenderer(대시보드 카드용, 반응형 line-grid)와 완전히 분리된 경로.
|
||||
* 팝업은 스튜디오에서 지정한 canvas(예: 837×632) 치수를 1:1 로 유지해야 하고
|
||||
* 반응형 재배치가 필요 없기 때문에 자체 absolute 좌표 파이프라인을 사용한다.
|
||||
*
|
||||
* 창 크기와 canvas 가 다른 경우의 전략:
|
||||
* 1) 팝업 부모는 `window.open` 에서 대략적인 크기로 열리고
|
||||
* (브라우저가 그 값을 무시할 수도 있음)
|
||||
* 2) 이 컴포넌트가 자체 컨테이너 크기(뷰포트)를 측정해서
|
||||
* canvas 를 `transform: scale(fit)` 로 뷰포트에 꽉 채운다
|
||||
* 3) 결과: 창 크기 = canvas × scale 로 빈 공간/잘림 없이 표시
|
||||
*
|
||||
* 공유: ensureV2Views(뷰 정규화) / BlockRenderer(컴포넌트 렌더) 만 재사용.
|
||||
* 그 외 canvas 좌표 계산/스케일/레이아웃은 이 파일 내부에서 완결.
|
||||
*/
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
BlockV2,
|
||||
CanvasV2,
|
||||
Template,
|
||||
} from '@/types/invyone-component';
|
||||
import { ensureV2Views } from '@/lib/utils/templateMigrate';
|
||||
import {
|
||||
BlockRenderer,
|
||||
type TemplateRenderContext,
|
||||
type ViewKey,
|
||||
} from './TemplateRenderer';
|
||||
// side-effect: 컴포넌트 레지스트리 등록 (BlockRenderer 가 사용)
|
||||
import '@/lib/registry/components';
|
||||
|
||||
interface PopupTemplateRendererProps {
|
||||
template: Template | any;
|
||||
context: TemplateRenderContext;
|
||||
/** 'create' | 'edit' — 팝업은 list 뷰가 의미 없음 */
|
||||
view: Exclude<ViewKey, 'list'>;
|
||||
}
|
||||
|
||||
export function PopupTemplateRenderer({
|
||||
template,
|
||||
context,
|
||||
view,
|
||||
}: PopupTemplateRendererProps) {
|
||||
// template.views 는 object 일수도, DB 에서 문자열로 넘어올 수도 있다.
|
||||
// 1차로 파싱 보정.
|
||||
const rawViews = useMemo(() => {
|
||||
const v = template?.views ?? (template as any)?.VIEWS;
|
||||
if (!v) return {} as Record<string, any>;
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
return JSON.parse(v) as Record<string, any>;
|
||||
} catch {
|
||||
return {} as Record<string, any>;
|
||||
}
|
||||
}
|
||||
return v as Record<string, any>;
|
||||
}, [template]);
|
||||
|
||||
const v2Views = useMemo(() => ensureV2Views(rawViews), [rawViews]);
|
||||
const currentView = view === 'create' ? v2Views.create : v2Views.edit;
|
||||
const blocks: BlockV2[] = currentView?.blocks ?? [];
|
||||
|
||||
// canvas 는 ensureV2Views 에만 의존하지 않고 원본 views 에서 직접 찾는다.
|
||||
// saveTemplate 은 뷰별 해상도를 `screenResolutions[view]` 로 저장한다.
|
||||
const canvas: CanvasV2 = useMemo(() => {
|
||||
const sr =
|
||||
rawViews?.viewCanvases?.[view] ??
|
||||
rawViews?.screenResolutions?.[view] ??
|
||||
rawViews?.screen_resolutions?.[view] ??
|
||||
rawViews?.screenResolution ??
|
||||
rawViews?.screen_resolution ??
|
||||
null;
|
||||
const w = Number(sr?.width ?? sr?.baseWidth);
|
||||
const h = Number(sr?.height ?? sr?.baseHeight);
|
||||
if (isFinite(w) && isFinite(h) && w > 0 && h > 0) {
|
||||
return { baseWidth: w, baseHeight: h, aspectPolicy: 'preserve' };
|
||||
}
|
||||
// ensureV2Views 가 만들어둔 canvas 가 있으면 그것, 아니면 최종 폴백.
|
||||
return (
|
||||
(v2Views?.viewCanvases as any)?.[view] ??
|
||||
v2Views?.canvas ?? {
|
||||
baseWidth: 1920,
|
||||
baseHeight: 1080,
|
||||
aspectPolicy: 'preserve',
|
||||
}
|
||||
);
|
||||
}, [rawViews, v2Views, view]);
|
||||
|
||||
// 뷰포트 크기 실측 — 부모 DOM 이 아닌 window 기준으로 측정해서
|
||||
// flex/overflow 레이아웃 타이밍 이슈를 피한다. 첫 렌더에도 바로 실제 값이
|
||||
// 들어가도록 lazy init(window 기반).
|
||||
const [viewport, setViewport] = useState<{ w: number; h: number }>(() => {
|
||||
if (typeof window === 'undefined') return { w: 1920, h: 1080 };
|
||||
return { w: window.innerWidth, h: window.innerHeight };
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const update = () =>
|
||||
setViewport({ w: window.innerWidth, h: window.innerHeight });
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// 팝업 창이 너무 크게 열린 경우 한 번 줄여보기 (resizeTo). 차단돼도 무관 —
|
||||
// scale fit 이 받아낸다.
|
||||
const didResizeRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didResizeRef.current) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const needInnerW = canvas.baseWidth + 16;
|
||||
const needInnerH = canvas.baseHeight + 8;
|
||||
const chromeW = Math.max(0, window.outerWidth - window.innerWidth);
|
||||
const chromeH = Math.max(0, window.outerHeight - window.innerHeight);
|
||||
const targetOuterW = needInnerW + chromeW;
|
||||
const targetOuterH = needInnerH + chromeH;
|
||||
if (
|
||||
Math.abs(targetOuterW - window.outerWidth) > 8 ||
|
||||
Math.abs(targetOuterH - window.outerHeight) > 8
|
||||
) {
|
||||
window.resizeTo(targetOuterW, targetOuterH);
|
||||
}
|
||||
} catch {
|
||||
/* 브라우저가 resizeTo 차단 — 무시. scale 이 보정 */
|
||||
}
|
||||
didResizeRef.current = true;
|
||||
}, [canvas.baseWidth, canvas.baseHeight]);
|
||||
|
||||
if (!blocks.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<div className="text-4xl opacity-40">📋</div>
|
||||
<div className="text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
이 뷰가 비어있습니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// scale 계산 — viewport 대비 canvas 가 들어가는 최대 비율. 1 이상이면 확대,
|
||||
// 1 이하면 축소. 어느 쪽이든 canvas 가 창을 꽉 채운다.
|
||||
const scale = useMemo(() => {
|
||||
// form-popup 페이지 상단 헤더(약 44px) 만 빼고 나머지를 canvas 영역으로 사용.
|
||||
const availW = viewport.w;
|
||||
const availH = Math.max(1, viewport.h - 44);
|
||||
const sx = availW / canvas.baseWidth;
|
||||
const sy = availH / canvas.baseHeight;
|
||||
const s = Math.min(sx, sy);
|
||||
const final = Number.isFinite(s) && s > 0 ? s : 1;
|
||||
if (typeof window !== 'undefined') {
|
||||
/* eslint-disable no-console */
|
||||
console.log('[PopupTemplateRenderer]', {
|
||||
viewportW: viewport.w,
|
||||
viewportH: viewport.h,
|
||||
canvasW: canvas.baseWidth,
|
||||
canvasH: canvas.baseHeight,
|
||||
sx,
|
||||
sy,
|
||||
scale: final,
|
||||
});
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
return final;
|
||||
}, [viewport, canvas.baseWidth, canvas.baseHeight]);
|
||||
|
||||
const displayW = canvas.baseWidth * scale;
|
||||
const displayH = canvas.baseHeight * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${displayW}px`,
|
||||
height: `${displayH}px`,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${canvas.baseWidth}px`,
|
||||
height: `${canvas.baseHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
{blocks.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className={`ppl-slot role-${block.role} policy-${block.responsivePolicy}`}
|
||||
data-comp={block.componentId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${block.xPct * canvas.baseWidth}px`,
|
||||
top: `${block.yPct * canvas.baseHeight}px`,
|
||||
width: `${block.wPct * canvas.baseWidth}px`,
|
||||
height: `${block.hPct * canvas.baseHeight}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<BlockRenderer
|
||||
block={block}
|
||||
context={context}
|
||||
view={view}
|
||||
canvas={canvas}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -617,10 +617,11 @@ const LINE_CSS = `
|
||||
`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BlockRenderer — ComponentRegistry 위임
|
||||
// BlockRenderer — ComponentRegistry 위임.
|
||||
// PopupTemplateRenderer 등 외부 파일에서도 재사용 가능하도록 export.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function BlockRenderer({
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
context,
|
||||
view,
|
||||
@@ -644,6 +645,27 @@ function BlockRenderer({
|
||||
block.config?.selectedTable ||
|
||||
block.config?.tableName ||
|
||||
context.primaryTable;
|
||||
const resolvedColumnName =
|
||||
block.config?.columnName ||
|
||||
block.config?.column_name ||
|
||||
block.config?.fieldKey ||
|
||||
block.config?.bindField ||
|
||||
block.config?.column;
|
||||
const resolvedValue =
|
||||
resolvedColumnName != null
|
||||
? context.formRow?.[resolvedColumnName]
|
||||
: undefined;
|
||||
const runtimeConfig =
|
||||
resolvedColumnName != null
|
||||
? { ...block.config, defaultValue: resolvedValue }
|
||||
: block.config;
|
||||
const handleFormValueChange = (fieldNameOrPatch: string | Record<string, any>, value?: any) => {
|
||||
if (typeof fieldNameOrPatch === 'string') {
|
||||
context.onFormRowChange?.({ [fieldNameOrPatch]: value });
|
||||
return;
|
||||
}
|
||||
context.onFormRowChange?.(fieldNameOrPatch);
|
||||
};
|
||||
|
||||
const def = ComponentRegistry.getComponent(block.componentId);
|
||||
if (!def?.component) {
|
||||
@@ -695,23 +717,36 @@ function BlockRenderer({
|
||||
id: block.id,
|
||||
componentType: block.componentId,
|
||||
tableName: resolvedTableName,
|
||||
columnName: resolvedColumnName,
|
||||
column_name: resolvedColumnName,
|
||||
value: resolvedValue,
|
||||
position,
|
||||
size: effectiveSize,
|
||||
componentConfig: block.config,
|
||||
component_config: block.config,
|
||||
componentConfig: runtimeConfig,
|
||||
component_config: runtimeConfig,
|
||||
style: {},
|
||||
}}
|
||||
componentConfig={block.config}
|
||||
config={block.config}
|
||||
componentConfig={runtimeConfig}
|
||||
config={runtimeConfig}
|
||||
tableName={resolvedTableName}
|
||||
columnName={resolvedColumnName}
|
||||
column_name={resolvedColumnName}
|
||||
value={resolvedValue}
|
||||
isDesignMode={false}
|
||||
isPreview={true}
|
||||
formData={context.formRow}
|
||||
form_data={context.formRow}
|
||||
onFormDataChange={(fieldName: string, value: any) =>
|
||||
context.onFormRowChange?.({ [fieldName]: value })
|
||||
handleFormValueChange(fieldName, value)
|
||||
}
|
||||
onChange={(value: any) =>
|
||||
resolvedColumnName ? handleFormValueChange(resolvedColumnName, value) : undefined
|
||||
}
|
||||
originalData={context.formRow}
|
||||
_originalData={context.formRow}
|
||||
onSearch={context.onSearch}
|
||||
searchParams={context.searchParams}
|
||||
onRowSelect={context.onRowSelect}
|
||||
onAdd={context.onAdd}
|
||||
onEdit={context.onEdit}
|
||||
onDelete={context.onDelete}
|
||||
|
||||
@@ -539,40 +539,93 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
|
||||
};
|
||||
|
||||
const handleModeSwitch = useCallback((_e?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleModeSwitch = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (modeTransition !== "idle") return;
|
||||
|
||||
// Simplified mode transition — sidebar items stagger morph only, no burst/sweep/badge theatrics.
|
||||
// Phase 1 (0ms): sidebar items morph-out (stagger 20ms)
|
||||
// Phase 2 (180ms): React swaps mode, new items morph-in (stagger 20ms)
|
||||
// Phase 3 (~400ms): cleanup → idle
|
||||
const goingToAdmin = !isAdminMode;
|
||||
setModeTransition("out");
|
||||
|
||||
// sidebar items morph-out (stagger 20ms)
|
||||
// (b) sidebar items morph-out — stagger
|
||||
const oldItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si"));
|
||||
oldItems.forEach((it, i) => {
|
||||
it.style.animationDelay = `${i * 20}ms`;
|
||||
it.style.animationDelay = `${i * 35}ms`;
|
||||
it.classList.add("mode-morph-out");
|
||||
});
|
||||
|
||||
// (c) header glow flash
|
||||
const hdrGlow = document.querySelector<HTMLElement>(".v5-hdr-glow");
|
||||
if (hdrGlow) {
|
||||
hdrGlow.classList.remove("mode-flash");
|
||||
void hdrGlow.offsetWidth;
|
||||
hdrGlow.classList.add("mode-flash");
|
||||
}
|
||||
|
||||
// (d) 토글 버튼 burst — ring 1 + radial particle 10
|
||||
const targetEl = e?.currentTarget as HTMLElement | undefined;
|
||||
const rect = targetEl?.getBoundingClientRect();
|
||||
const bx = rect ? rect.left + rect.width / 2 : (e?.clientX ?? window.innerWidth - 80);
|
||||
const by = rect ? rect.top + rect.height / 2 : (e?.clientY ?? 25);
|
||||
const burst = document.createElement("div");
|
||||
burst.className = `v5-mode-burst${goingToAdmin ? " admin" : ""}`;
|
||||
burst.style.left = `${bx}px`;
|
||||
burst.style.top = `${by}px`;
|
||||
const ring = document.createElement("span");
|
||||
ring.className = "burst-ring";
|
||||
burst.appendChild(ring);
|
||||
const N = 10;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = document.createElement("span");
|
||||
p.className = "burst-particle";
|
||||
const angle = (i / N) * Math.PI * 2;
|
||||
const dist = 36 + Math.random() * 22;
|
||||
p.style.setProperty("--tx", `${Math.cos(angle) * dist}px`);
|
||||
p.style.setProperty("--ty", `${Math.sin(angle) * dist}px`);
|
||||
p.style.animationDelay = `${i * 8}ms`;
|
||||
burst.appendChild(p);
|
||||
}
|
||||
document.body.appendChild(burst);
|
||||
setTimeout(() => burst.remove(), 1100);
|
||||
|
||||
// (d2) 헤더 하단 좌→우 sweep
|
||||
const hdrEl = document.querySelector<HTMLElement>(".v5-hdr");
|
||||
if (hdrEl) {
|
||||
const sweep = document.createElement("div");
|
||||
sweep.className = "v5-mode-sweep";
|
||||
sweep.setAttribute("data-mode", goingToAdmin ? "admin" : "user");
|
||||
hdrEl.appendChild(sweep);
|
||||
setTimeout(() => sweep.remove(), 900);
|
||||
}
|
||||
|
||||
// (e) breadcrumb swap-out
|
||||
const bc = document.querySelector<HTMLElement>(".v5-hdr-bc");
|
||||
bc?.classList.remove("mode-swap-in");
|
||||
bc?.classList.add("mode-swap-out");
|
||||
|
||||
setTimeout(() => {
|
||||
setTabMode(isAdminMode ? "user" : "admin");
|
||||
setModeTransition("in");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si"));
|
||||
newItems.forEach((it, i) => {
|
||||
it.style.animationDelay = `${i * 20}ms`;
|
||||
it.style.animationDelay = `${i * 45}ms`;
|
||||
it.classList.add("mode-morph-in");
|
||||
});
|
||||
|
||||
const newBc = document.querySelector<HTMLElement>(".v5-hdr-bc");
|
||||
newBc?.classList.remove("mode-swap-out");
|
||||
newBc?.classList.add("mode-swap-in");
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setModeTransition("idle");
|
||||
document.querySelectorAll<HTMLElement>(".v5-side .v5-si").forEach((it) => {
|
||||
it.classList.remove("mode-morph-in", "mode-morph-out");
|
||||
it.style.animationDelay = "";
|
||||
});
|
||||
}, 300);
|
||||
}, 180);
|
||||
document.querySelector<HTMLElement>(".v5-hdr-bc")?.classList.remove("mode-swap-in", "mode-swap-out");
|
||||
}, 600);
|
||||
}, 350);
|
||||
}, [isAdminMode, setTabMode, modeTransition]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -827,18 +880,38 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
{/* ===== Glass Header ===== */}
|
||||
<header className="v5-hdr">
|
||||
<div className="v5-hdr-l">
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="v5-mobile-toggle"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(!sidebarOpen);
|
||||
else setSidebarCollapsed(!sidebarCollapsed);
|
||||
}}
|
||||
title={isMobile ? (sidebarOpen ? "사이드바 닫기" : "사이드바 열기") : (sidebarCollapsed ? "사이드바 펼치기" : "사이드바 접기")}
|
||||
aria-label="사이드바 토글"
|
||||
>
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
{/* Mobile hamburger — 가로 모드(horizontal nav)에서는 데스크톱일 때 사이드바 자체가 없어 숨김 */}
|
||||
{(() => {
|
||||
const toggleHidden = !isMobile && navOrientation === "horizontal";
|
||||
return (
|
||||
<button
|
||||
className="v5-mobile-toggle"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(!sidebarOpen);
|
||||
else setSidebarCollapsed(!sidebarCollapsed);
|
||||
}}
|
||||
title={isMobile ? (sidebarOpen ? "사이드바 닫기" : "사이드바 열기") : (sidebarCollapsed ? "사이드바 펼치기" : "사이드바 접기")}
|
||||
aria-label="사이드바 토글"
|
||||
aria-hidden={toggleHidden}
|
||||
tabIndex={toggleHidden ? -1 : undefined}
|
||||
style={{
|
||||
opacity: toggleHidden ? 0 : 1,
|
||||
transform: toggleHidden ? "scale(.7) translateX(-8px)" : "scale(1) translateX(0)",
|
||||
width: toggleHidden ? 0 : undefined,
|
||||
minWidth: toggleHidden ? 0 : undefined,
|
||||
paddingLeft: toggleHidden ? 0 : undefined,
|
||||
paddingRight: toggleHidden ? 0 : undefined,
|
||||
marginRight: toggleHidden ? 0 : undefined,
|
||||
borderWidth: toggleHidden ? 0 : undefined,
|
||||
overflow: "hidden",
|
||||
pointerEvents: toggleHidden ? "none" : undefined,
|
||||
transition: "opacity .28s ease, transform .28s cubic-bezier(.22,1,.36,1), width .28s ease, min-width .28s ease, padding .28s ease, margin .28s ease, border-width .28s ease",
|
||||
}}
|
||||
>
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<div className="v5-hdr-logo">Invy.one</div>
|
||||
{navOrientation === "vertical" && (
|
||||
<>
|
||||
@@ -862,73 +935,76 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
|
||||
<div className="v5-hdr-glow" />
|
||||
<div className="v5-hdr-r">
|
||||
{/* 대시보드 페이지: [+ 대시보드] 를 편집/제어와 같은 그룹에 묶어 한 덩어리로.
|
||||
그 외 페이지: [+ 대시보드] 만 단독 노출. */}
|
||||
{pathname && !isAdminMode && /^\/\d+$/.test(pathname) ? (
|
||||
<div className="ud-htools">
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={openDashCreate}
|
||||
title="새 대시보드 만들기"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>대시보드</span>
|
||||
</button>
|
||||
{/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */}
|
||||
<AnimatedHtoolGroup show={dashEditMode && !dashControlActive}>
|
||||
{/* 헤더 도구군 — 대시보드 페이지(/숫자)에서만 노출, 그 외는 전부 숨김.
|
||||
- 사용자 + 대시보드 페이지: [+ 대시보드 | 편집 | 제어] + 외부 구분선
|
||||
- 사용자 + 생 main (대시보드 아님): 숨김
|
||||
- 관리자 모드: 숨김 */}
|
||||
{!isAdminMode && pathname && /^\/\d+$/.test(pathname) && (
|
||||
<>
|
||||
<div className="ud-htools">
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={() => openDashLib()}
|
||||
title="템플릿 추가"
|
||||
onClick={openDashCreate}
|
||||
title="새 대시보드 만들기"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>템플릿</span>
|
||||
<span>대시보드</span>
|
||||
</button>
|
||||
{/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */}
|
||||
<AnimatedHtoolGroup show={dashEditMode && !dashControlActive}>
|
||||
<span className="v5-hdr-sep" aria-hidden="true" />
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={() => openDashLib()}
|
||||
title="템플릿 추가"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>템플릿</span>
|
||||
</button>
|
||||
<span className="v5-hdr-sep" aria-hidden="true" />
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("dash:save"))}
|
||||
title="레이아웃 저장"
|
||||
>
|
||||
<Save size={11} />
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</AnimatedHtoolGroup>
|
||||
<span className="v5-hdr-sep" aria-hidden="true" />
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("dash:save"))}
|
||||
title="레이아웃 저장"
|
||||
className={`ud-htool${dashEditMode ? " on" : ""}`}
|
||||
onClick={toggleDashEditMode}
|
||||
disabled={dashControlActive}
|
||||
title={dashControlActive ? "제어 모드 중에는 편집 불가" : (dashEditMode ? "편집 종료" : "편집 모드")}
|
||||
>
|
||||
<Save size={11} />
|
||||
<span>저장</span>
|
||||
<Edit3 size={11} />
|
||||
<span>{dashEditMode ? "편집중" : "편집"}</span>
|
||||
</button>
|
||||
<span className="ud-hsep" />
|
||||
</AnimatedHtoolGroup>
|
||||
<button
|
||||
className={`ud-htool${dashEditMode ? " on" : ""}`}
|
||||
onClick={toggleDashEditMode}
|
||||
disabled={dashControlActive}
|
||||
title={dashControlActive ? "제어 모드 중에는 편집 불가" : (dashEditMode ? "편집 종료" : "편집 모드")}
|
||||
>
|
||||
<Edit3 size={11} />
|
||||
<span>{dashEditMode ? "편집중" : "편집"}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`ud-htool${dashControlActive ? " on" : ""}`}
|
||||
data-mode="ctrl"
|
||||
onClick={() => {
|
||||
if (!dashControlActive) setDashEditMode(false);
|
||||
toggleDashControlMode();
|
||||
}}
|
||||
title={dashControlActive ? "제어 종료" : "제어 모드 — 데이터 흐름 시각화"}
|
||||
>
|
||||
<Zap size={11} />
|
||||
<span>{dashControlActive ? "제어중" : "제어"}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="v5-dash-btn"
|
||||
onClick={openDashCreate}
|
||||
title="새 대시보드 만들기"
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>대시보드</span>
|
||||
</button>
|
||||
<span className="v5-hdr-sep" aria-hidden="true" />
|
||||
<button
|
||||
className={`ud-htool${dashControlActive ? " on" : ""}`}
|
||||
data-mode="ctrl"
|
||||
onClick={() => {
|
||||
if (!dashControlActive) setDashEditMode(false);
|
||||
toggleDashControlMode();
|
||||
}}
|
||||
title={dashControlActive ? "제어 종료" : "제어 모드 — 데이터 흐름 시각화"}
|
||||
>
|
||||
<Zap size={11} />
|
||||
<span>{dashControlActive ? "제어중" : "제어"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* 외부 구분선 — 부모 .v5-hdr-r 의 flex gap(.65rem) 이 양쪽에 붙는 걸
|
||||
음수 margin 으로 상쇄해 내부 구분선과 폭 맞춤 */}
|
||||
<span
|
||||
className="v5-hdr-sep"
|
||||
aria-hidden="true"
|
||||
style={{ margin: "0 -0.3rem" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="v5-hdr-sep" aria-hidden="true" />
|
||||
|
||||
{/* Theme toggle — single sun/moon icon (디자인시스템 준수, 2026-04-21) */}
|
||||
<button
|
||||
className="v5-hdr-icon"
|
||||
@@ -1069,15 +1145,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Sidebar toggle */}
|
||||
{!isMobile && (
|
||||
<button className="v5-side-toggle" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: sidebarCollapsed ? "rotate(180deg)" : "none", transition: "transform .3s" }}>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
<span>접기</span>
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
|
||||
@@ -126,29 +126,94 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
search: isDesignMode ? undefined : externalSearch,
|
||||
});
|
||||
|
||||
// ─── 렌더할 데이터 결정 ───
|
||||
const rows = isDesignMode
|
||||
? (tableData.data.length > 0
|
||||
? tableData.data.slice(0, DESIGN_PREVIEW_ROWS)
|
||||
: (columns.length > 0 ? [{}, {}, {}] : []))
|
||||
: tableData.data;
|
||||
|
||||
// ─── 행 선택 ───
|
||||
const [selectedRowIdx, setSelectedRowIdx] = useState<number | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
|
||||
const emitSelection = useCallback((nextSelectedRowIdx: number | null, nextSelectedRows: Set<number>) => {
|
||||
const runtimeRows = tableData.data;
|
||||
const onRowSelect =
|
||||
typeof (props as any).onRowSelect === "function"
|
||||
? ((props as any).onRowSelect as (row: Record<string, any>) => void)
|
||||
: undefined;
|
||||
const onSelectedRowsChange =
|
||||
typeof (props as any).onSelectedRowsChange === "function"
|
||||
? ((props as any).onSelectedRowsChange as (selectedRows: any[], selectedRowsData: any[]) => void)
|
||||
: undefined;
|
||||
|
||||
const selectedRow =
|
||||
nextSelectedRowIdx != null ? runtimeRows[nextSelectedRowIdx] ?? null : null;
|
||||
const selectedRowsData = Array.from(nextSelectedRows)
|
||||
.sort((a, b) => a - b)
|
||||
.map((rowIdx) => runtimeRows[rowIdx])
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedRow && onRowSelect) {
|
||||
onRowSelect(selectedRow);
|
||||
}
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(nextSelectedRows), selectedRowsData);
|
||||
}
|
||||
}, [props, tableData.data]);
|
||||
|
||||
const handleRowClick = useCallback((idx: number) => {
|
||||
if (isDesignMode) return;
|
||||
if (componentConfig.selectionMode === "multiple") {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(idx) ? next.delete(idx) : next.add(idx);
|
||||
setSelectedRowIdx(idx);
|
||||
emitSelection(idx, next);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedRowIdx(idx);
|
||||
const next = new Set<number>([idx]);
|
||||
setSelectedRows(next);
|
||||
emitSelection(idx, next);
|
||||
}
|
||||
}, [isDesignMode, componentConfig.selectionMode]);
|
||||
}, [isDesignMode, componentConfig.selectionMode, emitSelection]);
|
||||
|
||||
// ─── 렌더할 데이터 결정 ───
|
||||
const rows = isDesignMode
|
||||
? (tableData.data.length > 0
|
||||
? tableData.data.slice(0, DESIGN_PREVIEW_ROWS)
|
||||
: (columns.length > 0 ? [{}, {}, {}] : []))
|
||||
: tableData.data;
|
||||
const handleCheckboxToggle = useCallback((idx: number, checked?: boolean) => {
|
||||
if (isDesignMode) return;
|
||||
if (componentConfig.selectionMode === "multiple") {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
const shouldSelect = checked ?? !next.has(idx);
|
||||
if (shouldSelect) next.add(idx);
|
||||
else next.delete(idx);
|
||||
setSelectedRowIdx(shouldSelect ? idx : selectedRowIdx === idx ? null : selectedRowIdx);
|
||||
emitSelection(shouldSelect ? idx : selectedRowIdx === idx ? null : selectedRowIdx, next);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSelect = checked ?? selectedRowIdx !== idx;
|
||||
const nextIdx = shouldSelect ? idx : null;
|
||||
const next = nextIdx != null ? new Set<number>([nextIdx]) : new Set<number>();
|
||||
setSelectedRowIdx(nextIdx);
|
||||
setSelectedRows(next);
|
||||
emitSelection(nextIdx, next);
|
||||
}, [isDesignMode, componentConfig.selectionMode, emitSelection, selectedRowIdx]);
|
||||
|
||||
const handleToggleAll = useCallback((checked: boolean) => {
|
||||
if (isDesignMode || componentConfig.selectionMode !== "multiple") return;
|
||||
const next = checked
|
||||
? new Set(rows.map((_, idx) => idx))
|
||||
: new Set<number>();
|
||||
const nextSelectedIdx = checked && rows.length > 0 ? 0 : null;
|
||||
setSelectedRows(next);
|
||||
setSelectedRowIdx(nextSelectedIdx);
|
||||
emitSelection(nextSelectedIdx, next);
|
||||
}, [componentConfig.selectionMode, emitSelection, isDesignMode, rows]);
|
||||
|
||||
// ─── DOM props 필터 ───
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
@@ -233,7 +298,13 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
<tr>
|
||||
{showCheckbox && (
|
||||
<th style={{ ...thStyle, width: "32px", textAlign: "center" }}>
|
||||
<input type="checkbox" disabled={isDesignMode} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={componentConfig.selectionMode === "multiple" && rows.length > 0 && selectedRows.size === rows.length}
|
||||
onChange={(e) => handleToggleAll(e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isDesignMode || componentConfig.selectionMode !== "multiple" || rows.length === 0}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
@@ -282,8 +353,9 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
<td style={{ ...tdStyle, textAlign: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(idx)}
|
||||
onChange={() => handleRowClick(idx)}
|
||||
checked={componentConfig.selectionMode === "multiple" ? selectedRows.has(idx) : selectedRowIdx === idx}
|
||||
onChange={(e) => handleCheckboxToggle(idx, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isDesignMode}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -883,12 +883,27 @@
|
||||
to { opacity: 0; transform: translateX(8px); }
|
||||
}
|
||||
@keyframes ud-htool-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from { opacity: 0; transform: translateX(14px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes ud-sep-in {
|
||||
from { opacity: 0; transform: scaleY(.3); }
|
||||
to { opacity: 1; transform: scaleY(1); }
|
||||
from { opacity: 0; transform: translateX(14px) scaleY(.3); }
|
||||
to { opacity: 1; transform: translateX(0) scaleY(1); }
|
||||
}
|
||||
|
||||
/* ── Header tool stagger: 오른쪽 끝(제어 근처) 부터 왼쪽으로 스르륵 ── */
|
||||
.ud-htools > :nth-last-child(1) { animation-delay: 0ms; }
|
||||
.ud-htools > :nth-last-child(2) { animation-delay: 35ms; }
|
||||
.ud-htools > :nth-last-child(3) { animation-delay: 70ms; }
|
||||
.ud-htools > :nth-last-child(4) { animation-delay: 105ms; }
|
||||
.ud-htools > :nth-last-child(5) { animation-delay: 140ms; }
|
||||
.ud-htools > :nth-last-child(6) { animation-delay: 175ms; }
|
||||
.ud-htools > :nth-last-child(7) { animation-delay: 210ms; }
|
||||
|
||||
/* ud-htools 내부의 v5-hdr-sep + 외부 v5-hdr-sep 도 같은 슬라이드 */
|
||||
.ud-htools > .v5-hdr-sep,
|
||||
.ud-htools + .v5-hdr-sep {
|
||||
animation: ud-htool-in .45s var(--v5-ease-move) both;
|
||||
}
|
||||
|
||||
/* ── 카드 stagger: dash-canvas 내부 카드가 index × 35ms delay로 등장 ── */
|
||||
|
||||
Reference in New Issue
Block a user