diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index 73bca9fe..c52f367d 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -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(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 ( -
-
- - {/* 주요 관리 기능 */} -
-
-

주요 관리 기능

-

시스템의 핵심 관리 기능들을 제공합니다

-
-
- -
-
-
- -
-
-

사용자 관리

-

사용자 계정 및 권한 관리

-
-
+
+
+ {/* ===== 1) Welcome banner ===== */} +
+
+
+
+ {userInitial}
- - - {/*
-
-
- -
-
-

권한 관리

-

메뉴 및 기능 권한 설정

+
+
+ + {greeting} + · + {dateStr}
+

+ 환영합니다,{" "} + + {userName} + + 슈퍼 관리자님 +

+

+ 오늘도 좋은 하루 되십시오. 탑실 (COMPANY_27) · 오늘{" "} + 1,248건의 변경이 기록됐고,{" "} + 2건의 + 배치 오류가 주의를 기다리고 있습니다. +

- -
-
-
- -
-
-

시스템 설정

-

기본 설정 및 환경 구성

-
-
+
+ } label="전체 이력" /> + } label="배치" /> + } + label="관리 화면" + tone="primary" + />
- -
-
-
- -
-
-

통계 및 리포트

-

시스템 사용 현황 분석

-
-
-
*/} - - -
-
-
- -
-
-

화면관리

-

드래그앤드롭으로 화면 설계 및 관리

-
-
-
- - - -
-
-
- -
-
-

AI 어시스턴트

-

AI 채팅 및 LLM 연동 관리

-
-
-
-
-
+
- {/* 표준 관리 섹션 */} -
-
-

표준 관리

-

시스템 표준 및 컴포넌트를 통합 관리합니다

+ {/* ===== 2) Top stats × 4 ===== */} +
+ + + + +
+ + {/* ===== 3) System status × 4 ===== */} +
+ } + title="PostgreSQL" + status="green" + headline="정상" + caption="142.6 GB · QPS 218 · CPU 38%" + /> + } + title="배치 스케줄러" + status="amber" + headline="3 실행" + caption="24h 성공 184 · 실패 2 · 예정 12" + /> + } + title="외부 호출" + status="green" + headline="4,218" + caption="오늘 · 실패율 0.4% · 평균 412ms" + /> + } + title="워크플로우" + status="green" + headline="36 활성" + caption="오늘 실행 318 · 실패 2" + /> +
+ + {/* ===== 4) 회사별 활동 + 최근 변경 ===== */} +
+ {/* 회사별 활동 */} +
+
+
+
+ 회사별 활동 +
+
6개 회사
+
+ } + label="전체" + reverse + /> +
+
+ {COMPANY_ACTIVITY.map((r) => ( + + ))} +
-
- {/* -
-
-
- -
-
-

웹타입 관리

-

입력 컴포넌트 웹타입 표준 관리

-
-
-
- - -
-
-
- -
-
-

템플릿 관리

-

화면 디자이너 템플릿 표준 관리

-
+ {/* 최근 변경 */} +
+
+
+
+ 최근 변경
+
오늘
- */} - - -
-
-
- -
-
-

테이블 관리

-

데이터베이스 테이블 및 웹타입 매핑

-
-
-
- - - {/* -
-
-
- -
-
-

컴포넌트 관리

-

화면 디자이너 컴포넌트 표준 관리

-
-
-
- */} + } + label="더보기" + reverse + /> +
+
    + {RECENT_CHANGES.map((e, i) => ( + + ))} +
-
+
- {/* 빠른 액세스 */} -
-
-

빠른 액세스

-

자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다

+ {/* ===== 5) 빠른 진입 ===== */} +
+
+
+ 빠른 진입 +
+
+ 자주 쓰는 관리 화면 +
-
- -
-
-
- -
-
-

메뉴 관리

-

시스템 메뉴 및 네비게이션 설정

-
-
-
- - - -
-
-
- -
-
-

외부 연결 관리

-

외부 데이터베이스 연결 설정

-
-
-
- - - -
-
-
- -
-
-

공통 코드 관리

-

시스템 공통 코드 및 설정

-
-
-
- +
+ } + label="사용자관리" + /> + } + label="메뉴관리" + /> + } + label="권한관리" + /> + } + label="회사관리" + />
-
- - {/* 전역 파일 관리 */} -
-
-

전역 파일 관리

-

모든 페이지에서 업로드된 파일들을 관리합니다

-
- -
+
); } + +/* ────────────────────────────────────────────────────────── */ +/* Sub components */ +/* ────────────────────────────────────────────────────────── */ + +function AdminPill({ + icon, + label, + tone, + reverse, +}: { + icon?: React.ReactNode; + label: string; + tone?: "primary"; + reverse?: boolean; +}) { + const isPrimary = tone === "primary"; + return ( + + ); +} + +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 ( +
+
+ {label} +
+
+ + {value} + + {unit && ( + + {unit} + + )} +
+
+ {caption} +
+
+ ); +} + +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 ( +
+ +
+ + {icon} + + {title} +
+
+ {headline} +
+
+ {caption} +
+
+ ); +} + +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 ( +
+ + {row.code} + + {row.name} + + {row.users}{" "} + + + + {row.menus}{" "} + 메뉴 + + + {row.roles}{" "} + 권한 + + + {row.lastSeen} + +
+ ); +} + +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 ( +
  • + +
    +
    + {item.title} +
    +
    + {item.detail} +
    +
    + {item.timestamp} · {item.actor} +
    +
    +
  • + ); +} + +function QuickLink({ + href, + icon, + label, +}: { + href: string; + icon: React.ReactNode; + label: string; +}) { + return ( + + + + {icon} + + + {label} + + + + + ); +} diff --git a/frontend/app/form-popup/page.tsx b/frontend/app/form-popup/page.tsx index 107b0635..b28e2c42 100644 --- a/frontend/app/form-popup/page.tsx +++ b/frontend/app/form-popup/page.tsx @@ -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
    로딩 중...
    ; + // 부모가 localStorage 로 template 을 시드해주면 같은 useEffect 턴에 loaded=true + // 되므로 로딩 플래시가 사실상 보이지 않는다. 폴백 API 호출 중에만 잠깐 빈 화면. + if (!loaded) return null; if (error) return (
    ⚠ {error}
    @@ -211,20 +240,24 @@ function FormPopupContent() {
    -
    +
    {hasCustomView ? ( - ) : ( - handleSubmit(row)} - config={{ columns: 2 }} - /> +
    + handleSubmit(row)} + config={{ columns: 2 }} + /> +
    )}
    diff --git a/frontend/components/dash/DashboardCanvas.tsx b/frontend/components/dash/DashboardCanvas.tsx index 2b40ada8..84cf2678 100644 --- a/frontend/components/dash/DashboardCanvas.tsx +++ b/frontend/components/dash/DashboardCanvas.tsx @@ -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( return (
    {cards.length === 0 ? ( - + ) : ( cards.map((card) => { const id = card.card_id ?? card.CARD_ID; diff --git a/frontend/components/dash/DashboardCard.tsx b/frontend/components/dash/DashboardCard.tsx index 2c324f7d..e2f56902 100644 --- a/frontend/components/dash/DashboardCard.tsx +++ b/frontend/components/dash/DashboardCard.tsx @@ -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({ diff --git a/frontend/components/dash/DashboardEmpty.tsx b/frontend/components/dash/DashboardEmpty.tsx deleted file mode 100644 index 238c2a22..00000000 --- a/frontend/components/dash/DashboardEmpty.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; - -import { Plus } from 'lucide-react'; - -interface DashboardEmptyProps { - dashboardName: string; - onOpenLibrary: () => void; -} - -export function DashboardEmpty({ dashboardName, onOpenLibrary }: DashboardEmptyProps) { - return ( -
    { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onOpenLibrary(); - } - }} - style={{ cursor: 'pointer' }} - > -
    - -
    -
    {dashboardName}
    -
    - 이제 템플릿을 추가합니다. 이 영역을 클릭하거나 상단 템플릿 추가 버튼을 눌러주세요. -
    - -
    - ); -} diff --git a/frontend/components/dash/PopupTemplateRenderer.tsx b/frontend/components/dash/PopupTemplateRenderer.tsx new file mode 100644 index 00000000..1aa87867 --- /dev/null +++ b/frontend/components/dash/PopupTemplateRenderer.tsx @@ -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; +} + +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; + if (typeof v === 'string') { + try { + return JSON.parse(v) as Record; + } catch { + return {} as Record; + } + } + return v as Record; + }, [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 ( +
    +
    📋
    +
    + 이 뷰가 비어있습니다 +
    +
    + ); + } + + // 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 ( +
    +
    +
    + {blocks.map((block) => ( +
    + +
    + ))} +
    +
    +
    + ); +} diff --git a/frontend/components/dash/TemplateRenderer.tsx b/frontend/components/dash/TemplateRenderer.tsx index 19d1e472..d53dab1f 100644 --- a/frontend/components/dash/TemplateRenderer.tsx +++ b/frontend/components/dash/TemplateRenderer.tsx @@ -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, 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} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index d3081c6b..f046d74a 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -539,40 +539,93 @@ function AppLayoutInner({ children }: AppLayoutProps) { toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); }; - const handleModeSwitch = useCallback((_e?: React.MouseEvent) => { + const handleModeSwitch = useCallback((e?: React.MouseEvent) => { 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(".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(".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(".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(".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(".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(".v5-hdr-bc"); + newBc?.classList.remove("mode-swap-out"); + newBc?.classList.add("mode-swap-in"); }); + setTimeout(() => { setModeTransition("idle"); document.querySelectorAll(".v5-side .v5-si").forEach((it) => { it.classList.remove("mode-morph-in", "mode-morph-out"); it.style.animationDelay = ""; }); - }, 300); - }, 180); + document.querySelector(".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 ===== */}
    - {/* Mobile hamburger */} - + {/* Mobile hamburger — 가로 모드(horizontal nav)에서는 데스크톱일 때 사이드바 자체가 없어 숨김 */} + {(() => { + const toggleHidden = !isMobile && navOrientation === "horizontal"; + return ( + + ); + })()}
    Invy.one
    {navOrientation === "vertical" && ( <> @@ -862,73 +935,76 @@ function AppLayoutInner({ children }: AppLayoutProps) { {/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
    - {/* 대시보드 페이지: [+ 대시보드] 를 편집/제어와 같은 그룹에 묶어 한 덩어리로. - 그 외 페이지: [+ 대시보드] 만 단독 노출. */} - {pathname && !isAdminMode && /^\/\d+$/.test(pathname) ? ( -
    - - {/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */} - + {/* 헤더 도구군 — 대시보드 페이지(/숫자)에서만 노출, 그 외는 전부 숨김. + - 사용자 + 대시보드 페이지: [+ 대시보드 | 편집 | 제어] + 외부 구분선 + - 사용자 + 생 main (대시보드 아님): 숨김 + - 관리자 모드: 숨김 */} + {!isAdminMode && pathname && /^\/\d+$/.test(pathname) && ( + <> +
    + {/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */} + + +
    - ) : ( - +
    + {/* 외부 구분선 — 부모 .v5-hdr-r 의 flex gap(.65rem) 이 양쪽에 붙는 걸 + 음수 margin 으로 상쇄해 내부 구분선과 폭 맞춤 */} +
    - {/* Sidebar toggle */} - {!isMobile && ( - - )} )} diff --git a/frontend/lib/registry/components/table/TableComponent.tsx b/frontend/lib/registry/components/table/TableComponent.tsx index 4b3ea382..2618e0f6 100644 --- a/frontend/lib/registry/components/table/TableComponent.tsx +++ b/frontend/lib/registry/components/table/TableComponent.tsx @@ -126,29 +126,94 @@ export const TableComponent: React.FC = ({ 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(null); const [selectedRows, setSelectedRows] = useState>(new Set()); + const emitSelection = useCallback((nextSelectedRowIdx: number | null, nextSelectedRows: Set) => { + const runtimeRows = tableData.data; + const onRowSelect = + typeof (props as any).onRowSelect === "function" + ? ((props as any).onRowSelect as (row: Record) => 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([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([nextIdx]) : new Set(); + 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(); + 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 = ({ {showCheckbox && ( - + 0 && selectedRows.size === rows.length} + onChange={(e) => handleToggleAll(e.target.checked)} + onClick={(e) => e.stopPropagation()} + disabled={isDesignMode || componentConfig.selectionMode !== "multiple" || rows.length === 0} + /> )} {columns.map((col) => ( @@ -282,8 +353,9 @@ export const TableComponent: React.FC = ({ handleRowClick(idx)} + checked={componentConfig.selectionMode === "multiple" ? selectedRows.has(idx) : selectedRowIdx === idx} + onChange={(e) => handleCheckboxToggle(idx, e.target.checked)} + onClick={(e) => e.stopPropagation()} disabled={isDesignMode} /> diff --git a/frontend/styles/dashboard.css b/frontend/styles/dashboard.css index 1aa40972..41df72f4 100644 --- a/frontend/styles/dashboard.css +++ b/frontend/styles/dashboard.css @@ -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로 등장 ── */