admin 홈 리빌딩 + 폼팝업 전용 렌더러 + 테이블 선택 emit 강화
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:
DDD1542
2026-04-23 18:02:17 +09:00
parent 991b3aa831
commit 563aef6490
10 changed files with 1158 additions and 375 deletions
+554 -202
View File
@@ -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>
);
}
+48 -15
View File
@@ -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>
+2 -2
View File
@@ -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;
+19 -3
View File
@@ -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>
);
}
+42 -7
View File
@@ -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}
+155 -88
View File
@@ -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>
+19 -4
View File
@@ -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로 등장 ── */