From 7b6132953ca5f42be20c960cb42077585a92c431 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 16:20:19 +0900 Subject: [PATCH 01/76] =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/AppLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 00f467c7..17fa484a 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { isMobile ? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40" : "relative top-0 z-auto translate-x-0" - } flex h-[calc(100vh-3.5rem)] w-[240px] max-w-[240px] min-w-[240px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`} + } flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`} > {/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */} {((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" || From 95dc16160e1e4e9ce944ce8df3fc91aaaeadbd52 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 16:25:57 +0900 Subject: [PATCH 02/76] =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC(=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardListClient.tsx | 43 ++++--------- .../components/common/DeleteConfirmModal.tsx | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 frontend/components/common/DeleteConfirmModal.tsx diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index 8c55e366..a67761e4 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -13,18 +13,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { useToast } from "@/hooks/use-toast"; import { Pagination, PaginationInfo } from "@/components/common/Pagination"; +import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; interface DashboardListClientProps { @@ -307,26 +298,18 @@ export default function DashboardListClient({ initialDashboards, initialPaginati )} {/* 삭제 확인 모달 */} - - - - 대시보드 삭제 - - "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
+ + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. + + } + onConfirm={handleDeleteConfirm} + /> ); } diff --git a/frontend/components/common/DeleteConfirmModal.tsx b/frontend/components/common/DeleteConfirmModal.tsx new file mode 100644 index 00000000..6168a069 --- /dev/null +++ b/frontend/components/common/DeleteConfirmModal.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Loader2 } from "lucide-react"; + +interface DeleteConfirmModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: React.ReactNode; + onConfirm: () => void | Promise; + confirmText?: string; + isLoading?: boolean; +} + +/** + * 삭제 확인 모달 (공통 컴포넌트) + * - 표준 디자인: shadcn AlertDialog 기반 + * - 반응형: 모바일/데스크톱 최적화 + * - 로딩 상태 지원 + */ +export function DeleteConfirmModal({ + open, + onOpenChange, + title, + description, + onConfirm, + confirmText = "삭제", + isLoading = false, +}: DeleteConfirmModalProps) { + return ( + + + + {title} + {description} + + + + 취소 + + + {isLoading && } + {confirmText} + + + + + ); +} From 5d1d11869c573c5088fcdadfb6136417949995cb Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 18:05:45 +0900 Subject: [PATCH 03/76] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardListClient.tsx | 267 +++++++++++++----- .../app/(main)/admin/dashboard/new/page.tsx | 3 - .../admin/dashboard/DashboardDesigner.tsx | 25 +- .../admin/dashboard/DashboardTopMenu.tsx | 100 +++---- .../dashboard/data-sources/DatabaseConfig.tsx | 26 +- 5 files changed, 255 insertions(+), 166 deletions(-) diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index a67761e4..d8eeae61 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -181,14 +181,19 @@ export default function DashboardListClient({ initialDashboards, initialPaginati <> {/* 검색 및 액션 */}
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ 총 {totalCount.toLocaleString()} 건 +
+ + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2 text-sm" + > + + 편집 + + handleCopy(dashboard)} className="gap-2 text-sm"> + + 복사 + + handleDeleteClick(dashboard.id, dashboard.title)} + className="text-destructive focus:text-destructive gap-2 text-sm" + > + + 삭제 + + + + + + ))} + + +
+ + {/* 모바일/태블릿 카드 뷰 (lg 미만) */} +
+ {dashboards.map((dashboard) => ( +
+ {/* 헤더 */} +
+
+

{dashboard.title}

+

{dashboard.id}

+
+
+ + {/* 정보 */} +
+
+ 설명 + {dashboard.description || "-"} +
+
+ 생성일 + {formatDate(dashboard.createdAt)} +
+
+ 수정일 + {formatDate(dashboard.updatedAt)} +
+
+ + {/* 액션 */} +
+ + + +
+
+ ))} +
+ )} {/* 페이지네이션 */} diff --git a/frontend/app/(main)/admin/dashboard/new/page.tsx b/frontend/app/(main)/admin/dashboard/new/page.tsx index d2f2ce11..56d28f46 100644 --- a/frontend/app/(main)/admin/dashboard/new/page.tsx +++ b/frontend/app/(main)/admin/dashboard/new/page.tsx @@ -1,6 +1,3 @@ -"use client"; - -import React from "react"; import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; /** diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index e643b9bd..39546d91 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,14 +7,7 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigSidebar } from "./ElementConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { - GRID_CONFIG, - snapToGrid, - snapSizeToGrid, - calculateCellSize, - calculateGridConfig, - calculateBoxSize, -} from "./gridUtils"; +import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; @@ -199,7 +192,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D ...el, dataSources: el.chartConfig?.dataSources || el.dataSources, })); - + setElements(elementsWithDataSources); // elementCounter를 가장 큰 ID 번호로 설정 @@ -582,11 +575,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 로딩 중이면 로딩 화면 표시 if (isLoading) { return ( -
+
-
대시보드 로딩 중...
-
잠시만 기다려주세요
+
대시보드 로딩 중...
+
잠시만 기다려주세요
); @@ -594,7 +587,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return ( -
+
{/* 상단 메뉴바 */} +
-
- +
+
저장 완료 대시보드가 성공적으로 저장되었습니다. diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 611c7a97..7c9505e2 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -78,10 +78,9 @@ export function DashboardTopMenu({ dataUrl: string, format: "png" | "pdf", canvasWidth: number, - canvasHeight: number + canvasHeight: number, ) => { if (format === "png") { - console.log("💾 PNG 다운로드 시작..."); const link = document.createElement("a"); const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`; link.download = filename; @@ -89,11 +88,9 @@ export function DashboardTopMenu({ document.body.appendChild(link); link.click(); document.body.removeChild(link); - console.log("✅ PNG 다운로드 완료:", filename); } else { - console.log("📄 PDF 생성 중..."); const jsPDF = (await import("jspdf")).default; - + // dataUrl에서 이미지 크기 계산 const img = new Image(); img.src = dataUrl; @@ -101,17 +98,12 @@ export function DashboardTopMenu({ img.onload = resolve; }); - console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height }); - console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight }); - // PDF 크기 계산 (A4 기준) const imgWidth = 210; // A4 width in mm const actualHeight = canvasHeight; const actualWidth = canvasWidth; const imgHeight = (actualHeight * imgWidth) / actualWidth; - console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight }); - const pdf = new jsPDF({ orientation: imgHeight > imgWidth ? "portrait" : "landscape", unit: "mm", @@ -121,53 +113,44 @@ export function DashboardTopMenu({ pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`; pdf.save(filename); - console.log("✅ PDF 다운로드 완료:", filename); } }; const handleDownload = async (format: "png" | "pdf") => { try { - console.log("🔍 다운로드 시작:", format); - // 실제 위젯들이 있는 캔버스 찾기 const canvas = document.querySelector(".dashboard-canvas") as HTMLElement; - console.log("🔍 캔버스 찾기:", canvas); - + if (!canvas) { alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요."); return; } - console.log("📸 html-to-image 로딩 중..."); // html-to-image 동적 import - const { toPng, toJpeg } = await import("html-to-image"); + // @ts-expect-error - 동적 import + const { toPng } = await import("html-to-image"); - console.log("📸 캔버스 캡처 중..."); - // 3D/WebGL 렌더링 완료 대기 - console.log("⏳ 3D 렌더링 완료 대기 중..."); await new Promise((resolve) => setTimeout(resolve, 1000)); - + // WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존) - console.log("🎨 WebGL 캔버스 처리 중..."); const webglCanvases = canvas.querySelectorAll("canvas"); const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = []; - + webglCanvases.forEach((webglCanvas) => { try { const rect = webglCanvas.getBoundingClientRect(); const dataUrl = webglCanvas.toDataURL("image/png"); webglImages.push({ canvas: webglCanvas, dataUrl, rect }); - console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height }); - } catch (error) { - console.warn("⚠️ WebGL 캔버스 캡처 실패:", error); + } catch { + // WebGL 캔버스 캡처 실패 시 무시 } }); - + // 캔버스의 실제 크기와 위치 가져오기 const rect = canvas.getBoundingClientRect(); const canvasWidth = canvas.scrollWidth; - + // 실제 콘텐츠의 최하단 위치 계산 const children = canvas.querySelectorAll(".canvas-element"); let maxBottom = 0; @@ -178,17 +161,9 @@ export function DashboardTopMenu({ maxBottom = relativeBottom; } }); - + // 실제 콘텐츠 높이 + 여유 공간 (50px) const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight; - - console.log("📐 캔버스 정보:", { - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - scroll: { width: canvasWidth, height: canvas.scrollHeight }, - calculated: { width: canvasWidth, height: canvasHeight }, - maxBottom: maxBottom, - webglCount: webglImages.length - }); // html-to-image로 캔버스 캡처 (WebGL 제외) const getDefaultBackgroundColor = () => { @@ -204,8 +179,8 @@ export function DashboardTopMenu({ pixelRatio: 2, // 고해상도 cacheBust: true, skipFonts: false, - preferredFontFormat: 'woff2', - filter: (node) => { + preferredFontFormat: "woff2", + filter: (node: Node) => { // WebGL 캔버스는 제외 (나중에 수동으로 합성) if (node instanceof HTMLCanvasElement) { return false; @@ -213,26 +188,25 @@ export function DashboardTopMenu({ return true; }, }); - + // WebGL 캔버스를 이미지 위에 합성 if (webglImages.length > 0) { - console.log("🖼️ WebGL 이미지 합성 중..."); const img = new Image(); img.src = dataUrl; await new Promise((resolve) => { img.onload = resolve; }); - + // 새 캔버스에 합성 const compositeCanvas = document.createElement("canvas"); compositeCanvas.width = img.width; compositeCanvas.height = img.height; const ctx = compositeCanvas.getContext("2d"); - + if (ctx) { // 기본 이미지 그리기 ctx.drawImage(img, 0, 0); - + // WebGL 이미지들을 위치에 맞게 그리기 for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) { const webglImg = new Image(); @@ -240,50 +214,45 @@ export function DashboardTopMenu({ await new Promise((resolve) => { webglImg.onload = resolve; }); - + // 상대 위치 계산 (pixelRatio 2 고려) const relativeX = (webglRect.left - rect.left) * 2; const relativeY = (webglRect.top - rect.top) * 2; const width = webglRect.width * 2; const height = webglRect.height * 2; - + ctx.drawImage(webglImg, relativeX, relativeY, width, height); - console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height }); } - + // 합성된 이미지를 dataUrl로 변환 const compositeDataUrl = compositeCanvas.toDataURL("image/png"); - console.log("✅ 최종 합성 완료"); - + // 기존 dataUrl을 합성된 것으로 교체 return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight); } } - console.log("✅ 캡처 완료 (WebGL 없음)"); - // WebGL이 없는 경우 기본 다운로드 await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight); } catch (error) { - console.error("❌ 다운로드 실패:", error); alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`); } }; return ( -
+
{/* 좌측: 대시보드 제목 */} -
+
{dashboardTitle && (
- {dashboardTitle} - 편집 중 + {dashboardTitle} + 편집 중
)}
{/* 중앙: 해상도 선택 & 요소 추가 */} -
+
{/* 해상도 선택 */} {onResolutionChange && ( )} -
+
{/* 배경색 선택 */} {onBackgroundColorChange && ( @@ -301,7 +270,7 @@ export function DashboardTopMenu({ @@ -355,7 +324,7 @@ export function DashboardTopMenu({ )} -
+
{/* 차트 선택 */} {selectedConnection && ( -
+
커넥션: {selectedConnection.connection_name}
From 9953014b888381b797d8b1e6cac47e5fd8cd42d0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 18:10:52 +0900 Subject: [PATCH 04/76] =?UTF-8?q?DashboardDesigner=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 71 ++----------------- 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 39546d91..4f1b546b 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -140,12 +140,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { - console.log("📝 기존 대시보드 편집 모드"); loadDashboard(initialDashboardId); - } else { - console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialDashboardId]); // 대시보드 데이터 로드 @@ -155,35 +151,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const { dashboardApi } = await import("@/lib/api/dashboard"); const dashboard = await dashboardApi.getDashboard(id); - console.log("📊 대시보드 로드:", { - id: dashboard.id, - title: dashboard.title, - settings: dashboard.settings, - settingsType: typeof dashboard.settings, - }); - // 대시보드 정보 설정 setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); // 저장된 설정 복원 const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings; - console.log("🎨 설정 복원:", { - settings, - resolution: settings?.resolution, - backgroundColor: settings?.backgroundColor, - }); // 배경색 설정 if (settings?.backgroundColor) { setCanvasBackgroundColor(settings.backgroundColor); - console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor); } // 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함) const loadedResolution = settings?.resolution || "fhd"; setResolution(loadedResolution); - console.log("✅ Resolution 설정됨:", loadedResolution); // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { @@ -221,7 +203,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D (type: ElementType, subtype: ElementSubtype, x: number, y: number) => { // 좌표 유효성 검사 if (isNaN(x) || isNaN(y)) { - // console.error("Invalid coordinates:", { x, y }); return; } @@ -246,14 +227,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 크기 유효성 검사 if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { - // console.error("Invalid size calculated:", { - // canvasConfig, - // cellSize, - // cellWithGap, - // defaultCells, - // defaultWidth, - // defaultHeight, - // }); return; } @@ -287,7 +260,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 좌표 유효성 확인 if (isNaN(centerX) || isNaN(centerY)) { - // console.error("Invalid canvas config:", canvasConfig); return; } @@ -375,16 +347,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setClearConfirmOpen(false); }, []); - // 리스트/야드 위젯 설정 저장 (Partial 업데이트) - const saveWidgetConfig = useCallback( - (updates: Partial) => { - if (sidebarElement) { - updateElement(sidebarElement.id, updates); - } - }, - [sidebarElement, updateElement], - ); - // 사이드바 닫기 const handleCloseSidebar = useCallback(() => { setSidebarOpen(false); @@ -436,14 +398,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const { dashboardApi } = await import("@/lib/api/dashboard"); const elementsData = elements.map((el) => { - // 야드 위젯인 경우 설정 로그 출력 - // if (el.subtype === "yard-management-3d") { - // console.log("💾 야드 위젯 저장:", { - // id: el.id, - // yardConfig: el.yardConfig, - // hasLayoutId: !!el.yardConfig?.layoutId, - // }); - // } return { id: el.id, type: el.type, @@ -487,12 +441,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D }, }; - console.log("💾 대시보드 업데이트 요청:", { - dashboardId, - updateData, - elementsCount: elementsData.length, - }); - savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); } else { // 새 대시보드 생성 @@ -553,18 +501,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 성공 모달 표시 setSuccessModalOpen(true); } catch (error) { - console.error("❌ 대시보드 저장 실패:", error); const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; - - // 상세한 에러 정보 로깅 - if (error instanceof Error) { - console.error("Error details:", { - message: error.message, - stack: error.stack, - name: error.name, - }); - } - alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`); throw error; } @@ -754,13 +691,13 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "달력 위젯"; case "driver-management": return "기사 관리 위젯"; - case "list": + case "list-v2": return "리스트 위젯"; - case "map-summary": + case "map-summary-v2": return "커스텀 지도 카드"; case "status-summary": return "커스텀 상태 카드"; - case "risk-alert": + case "risk-alert-v2": return "리스크 알림 위젯"; case "todo": return "할 일 위젯"; @@ -814,7 +751,7 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "calendar"; case "driver-management": return "driver-management"; - case "list": + case "list-v2": return "list-widget"; case "yard-management-3d": return "yard-3d"; From e08671923562a4a0bfc402c89c80691764187c26 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 11:02:06 +0900 Subject: [PATCH 05/76] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 6 +- .../admin/dashboard/ElementConfigSidebar.tsx | 563 ------------------ .../admin/dashboard/WidgetConfigSidebar.tsx | 485 +++++++++++++++ .../widget-sections/ChartConfigSection.tsx | 46 ++ .../widget-sections/CustomMetricSection.tsx | 49 ++ .../widget-sections/ListWidgetSection.tsx | 41 ++ .../widget-sections/MapConfigSection.tsx | 47 ++ .../widget-sections/RiskAlertSection.tsx | 47 ++ .../widgets/ListWidgetConfigSidebar.tsx | 260 -------- .../widgets/YardWidgetConfigSidebar.tsx | 119 ---- .../CustomMetricConfigSidebar.tsx | 516 ---------------- 11 files changed, 718 insertions(+), 1461 deletions(-) delete mode 100644 frontend/components/admin/dashboard/ElementConfigSidebar.tsx create mode 100644 frontend/components/admin/dashboard/WidgetConfigSidebar.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx delete mode 100644 frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx delete mode 100644 frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx delete mode 100644 frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 4f1b546b..cdd080b6 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -4,7 +4,7 @@ import React, { useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; -import { ElementConfigSidebar } from "./ElementConfigSidebar"; +import { WidgetConfigSidebar } from "./WidgetConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils"; @@ -581,8 +581,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
- {/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */} - void; - onApply: (element: DashboardElement) => void; -} - -/** - * 요소 설정 사이드바 컴포넌트 - * - 왼쪽에서 슬라이드 인/아웃 - * - 캔버스 위에 오버레이 - * - "적용" 버튼으로 명시적 저장 - */ -export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) { - const [dataSource, setDataSource] = useState({ - type: "database", - connectionType: "current", - refreshInterval: 0, - }); - const [dataSources, setDataSources] = useState([]); - const [chartConfig, setChartConfig] = useState({}); - const [queryResult, setQueryResult] = useState(null); - const [customTitle, setCustomTitle] = useState(""); - const [showHeader, setShowHeader] = useState(true); - - // 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용) - const [testResults, setTestResults] = useState[] }>>( - new Map(), - ); - - // 사이드바가 열릴 때 초기화 - useEffect(() => { - if (isOpen && element) { - setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); - - // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 - // ⚠️ 중요: 없으면 반드시 빈 배열로 초기화 - const initialDataSources = element.dataSources || element.chartConfig?.dataSources || []; - setDataSources(initialDataSources); - - setChartConfig(element.chartConfig || {}); - setQueryResult(null); - setTestResults(new Map()); // 테스트 결과도 초기화 - setCustomTitle(element.customTitle || ""); - setShowHeader(element.showHeader !== false); - } else if (!isOpen) { - // 사이드바가 닫힐 때 모든 상태 초기화 - setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 }); - setDataSources([]); - setChartConfig({}); - setQueryResult(null); - setTestResults(new Map()); - setCustomTitle(""); - setShowHeader(true); - } - }, [isOpen, element]); - - // Esc 키로 사이드바 닫기 - useEffect(() => { - if (!isOpen) return; - - const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("keydown", handleEsc); - return () => window.removeEventListener("keydown", handleEsc); - }, [isOpen, onClose]); - - // 데이터 소스 타입 변경 - const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { - if (type === "database") { - setDataSource({ - type: "database", - connectionType: "current", - refreshInterval: 0, - }); - } else { - setDataSource({ - type: "api", - method: "GET", - refreshInterval: 0, - }); - } - - setQueryResult(null); - setChartConfig({}); - }, []); - - // 데이터 소스 업데이트 - const handleDataSourceUpdate = useCallback((updates: Partial) => { - setDataSource((prev) => ({ ...prev, ...updates })); - }, []); - - // 차트 설정 변경 처리 - const handleChartConfigChange = useCallback( - (newConfig: ChartConfig) => { - setChartConfig(newConfig); - - // 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-summary-v2 위젯용) - if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) { - onApply({ - ...element, - chartConfig: newConfig, - dataSource: dataSource, - customTitle: customTitle, - showHeader: showHeader, - }); - } - }, - [element, dataSource, customTitle, showHeader, onApply], - ); - - // 쿼리 테스트 결과 처리 - const handleQueryTest = useCallback((result: QueryResult) => { - setQueryResult(result); - setChartConfig({}); - }, []); - - // 적용 처리 - const handleApply = useCallback(() => { - if (!element) return; - - // 다중 데이터 소스 위젯 체크 - const isMultiDS = - element.subtype === "map-summary-v2" || - element.subtype === "chart" || - element.subtype === "list-v2" || - element.subtype === "custom-metric-v2" || - element.subtype === "risk-alert-v2"; - - const updatedElement: DashboardElement = { - ...element, - // 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장 - chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig, - dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성 - dataSource: isMultiDS ? undefined : dataSource, - customTitle: customTitle.trim() || undefined, - showHeader, - }; - - onApply(updatedElement); - // 사이드바는 열린 채로 유지 (연속 수정 가능) - }, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]); - - // 요소가 없으면 렌더링하지 않음 - if (!element) return null; - - // 리스트 위젯은 별도 사이드바로 처리 - if (element.subtype === "list-v2") { - return ( - { - onApply(updatedElement); - }} - /> - ); - } - - // 야드 위젯은 사이드바로 처리 - if (element.subtype === "yard-management-3d") { - return ( - { - onApply({ ...element, ...updates }); - }} - onClose={onClose} - /> - ); - } - - // 사용자 커스텀 카드 위젯은 사이드바로 처리 - if (element.subtype === "custom-metric-v2") { - return ( - { - onApply({ ...element, ...updates }); - }} - /> - ); - } - - // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) - const isSimpleWidget = - element.subtype === "todo" || - element.subtype === "booking-alert" || - element.subtype === "maintenance" || - element.subtype === "document" || - element.subtype === "vehicle-status" || - element.subtype === "vehicle-list" || - element.subtype === "status-summary" || - element.subtype === "delivery-status" || - element.subtype === "delivery-status-summary" || - element.subtype === "delivery-today-stats" || - element.subtype === "cargo-list" || - element.subtype === "customer-issues" || - element.subtype === "driver-management" || - element.subtype === "work-history" || - element.subtype === "transport-stats"; - - // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) - const isSelfContainedWidget = - element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator"; - - // 지도 위젯 (위도/경도 매핑 필요) - const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary-v2"; - - // 헤더 전용 위젯 - const isHeaderOnlyWidget = - element.type === "widget" && - (element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget); - - // 다중 데이터 소스 위젯 - const isMultiDataSourceWidget = - (element.subtype as string) === "map-summary-v2" || - (element.subtype as string) === "chart" || - (element.subtype as string) === "list-v2" || - (element.subtype as string) === "custom-metric-v2" || - (element.subtype as string) === "risk-alert-v2"; - - // 저장 가능 여부 확인 - const isPieChart = element.subtype === "pie" || element.subtype === "donut"; - const isApiSource = dataSource.type === "api"; - - const hasYAxis = - chartConfig.yAxis && - (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); - - const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); - const isHeaderChanged = showHeader !== (element.showHeader !== false); - - const canApply = - isTitleChanged || - isHeaderChanged || - (isMultiDataSourceWidget - ? true // 다중 데이터 소스 위젯은 항상 적용 가능 - : isSimpleWidget - ? queryResult && queryResult.rows.length > 0 - : isMapWidget - ? element.subtype === "map-summary-v2" - ? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 지도 위젯: 타일맵 URL 또는 API 데이터 - : queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn - : queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis)); - - return ( -
- {/* 헤더 */} -
-
-
- -
- {element.title} -
- -
- - {/* 본문: 스크롤 가능 영역 */} -
- {/* 기본 설정 카드 */} -
-
기본 설정
-
- {/* 커스텀 제목 입력 */} -
- setCustomTitle(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - placeholder="위젯 제목" - className="bg-muted focus:bg-background h-8 text-xs" - /> -
- - {/* 헤더 표시 옵션 */} -
- setShowHeader(checked === true)} - /> - -
-
-
- - {/* 다중 데이터 소스 위젯 */} - {isMultiDataSourceWidget && ( - <> -
- { - // API 테스트 결과를 queryResult로 설정 (차트 설정용) - setQueryResult({ - ...result, - totalRows: result.rows.length, - executionTime: 0, - }); - - // 각 데이터 소스의 테스트 결과 저장 - setTestResults((prev) => { - const updated = new Map(prev); - updated.set(dataSourceId, result); - return updated; - }); - }} - /> -
- - {/* 지도 위젯: 타일맵 URL 설정 */} - {element.subtype === "map-summary-v2" && ( -
-
- -
-
- 타일맵 설정 (선택사항) -
-
기본 VWorld 타일맵 사용 중
-
- - - -
-
- -
-
-
- )} - - {/* 차트 위젯: 차트 설정 */} - {element.subtype === "chart" && ( -
-
- -
-
- 차트 설정 -
-
- {testResults.size > 0 - ? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정` - : "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"} -
-
- - - -
-
- -
-
-
- )} - - )} - - {/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */} - {!isHeaderOnlyWidget && !isMultiDataSourceWidget && ( -
-
- 데이터 소스 -
- - handleDataSourceTypeChange(value as "database" | "api")} - className="w-full" - > - - - 데이터베이스 - - - REST API - - - - - - - - {/* 차트/지도 설정 */} - {!isSimpleWidget && - (element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && ( -
- {isMapWidget ? ( - element.subtype === "map-summary-v2" ? ( - - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - ) - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - )} -
- )} -
- - - - - {/* 차트/지도 설정 */} - {!isSimpleWidget && - (element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && ( -
- {isMapWidget ? ( - element.subtype === "map-summary-v2" ? ( - - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - ) - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - )} -
- )} -
-
- - {/* 데이터 로드 상태 */} - {queryResult && ( -
-
- {queryResult.rows.length}개 데이터 로드됨 -
- )} -
- )} -
- - {/* 푸터: 적용 버튼 */} -
- - -
-
- ); -} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx new file mode 100644 index 00000000..f4104e73 --- /dev/null +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -0,0 +1,485 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { + DashboardElement, + ChartDataSource, + ElementSubtype, + QueryResult, + ListWidgetConfig, + ChartConfig, + CustomMetricConfig, +} from "./types"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { DatabaseConfig } from "./data-sources/DatabaseConfig"; +import { ApiConfig } from "./data-sources/ApiConfig"; +import { QueryEditor } from "./QueryEditor"; +import { ListWidgetSection } from "./widget-sections/ListWidgetSection"; +import { ChartConfigSection } from "./widget-sections/ChartConfigSection"; +import { CustomMetricSection } from "./widget-sections/CustomMetricSection"; +import { MapConfigSection } from "./widget-sections/MapConfigSection"; +import { RiskAlertSection } from "./widget-sections/RiskAlertSection"; + +interface WidgetConfigSidebarProps { + element: DashboardElement | null; + isOpen: boolean; + onClose: () => void; + onApply: (element: DashboardElement) => void; +} + +// 위젯 분류 헬퍼 함수 +const needsDataSource = (subtype: ElementSubtype): boolean => { + // 차트 타입들 + const chartTypes = ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"]; + + const dataWidgets = [ + "list-v2", + "custom-metric-v2", + "chart", + "map-summary-v2", + "risk-alert-v2", + "yard-management-3d", + "todo", + "document", + "work-history", + "transport-stats", + "booking-alert", + "maintenance", + "vehicle-status", + "vehicle-list", + "status-summary", + "delivery-status", + "delivery-status-summary", + "delivery-today-stats", + "cargo-list", + "customer-issues", + "driver-management", + ]; + + return chartTypes.includes(subtype) || dataWidgets.includes(subtype); +}; + +const getWidgetIcon = (subtype: ElementSubtype): string => { + const iconMap: Record = { + "list-v2": "📋", + "custom-metric-v2": "📊", + chart: "📈", + "map-summary-v2": "🗺️", + "risk-alert-v2": "⚠️", + "yard-management-3d": "🏗️", + weather: "🌤️", + exchange: "💱", + calculator: "🧮", + clock: "🕐", + calendar: "📅", + todo: "✅", + document: "📄", + }; + return iconMap[subtype] || "🔧"; +}; + +const getWidgetTitle = (subtype: ElementSubtype): string => { + const titleMap: Record = { + "list-v2": "리스트 위젯", + "custom-metric-v2": "통계 카드", + chart: "차트", + "map-summary-v2": "지도", + "risk-alert-v2": "리스크 알림", + "yard-management-3d": "야드 관리 3D", + weather: "날씨 위젯", + exchange: "환율 위젯", + calculator: "계산기", + clock: "시계", + calendar: "달력", + todo: "할 일", + document: "문서", + }; + return titleMap[subtype] || "위젯"; +}; + +/** + * 통합 위젯 설정 사이드바 + * - 모든 위젯 타입에 대한 일관된 설정 UI 제공 + * - 일반 탭: 제목, 헤더 표시 설정 + * - 데이터 탭: 데이터 소스 및 위젯별 커스텀 설정 + */ +export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: WidgetConfigSidebarProps) { + // 일반 설정 state + const [customTitle, setCustomTitle] = useState(""); + const [showHeader, setShowHeader] = useState(true); + + // 데이터 소스 state + const [dataSource, setDataSource] = useState({ + type: "database", + connectionType: "current", + refreshInterval: 0, + }); + + // 쿼리 결과 + const [queryResult, setQueryResult] = useState(null); + + // 리스트 위젯 설정 + const [listConfig, setListConfig] = useState({ + viewMode: "table", + columns: [], + pageSize: 10, + enablePagination: true, + showHeader: true, + stripedRows: true, + compactMode: false, + cardColumns: 3, + }); + + // 차트 설정 + const [chartConfig, setChartConfig] = useState({}); + + // 커스텀 메트릭 설정 + const [customMetricConfig, setCustomMetricConfig] = useState({ metrics: [] }); + + // 사이드바 열릴 때 초기화 + useEffect(() => { + if (isOpen && element) { + setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); + setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + setQueryResult(null); + + // 리스트 위젯 설정 초기화 + if (element.subtype === "list-v2" && element.listConfig) { + setListConfig(element.listConfig); + } else { + setListConfig({ + viewMode: "table", + columns: [], + pageSize: 10, + enablePagination: true, + showHeader: true, + stripedRows: true, + compactMode: false, + cardColumns: 3, + }); + } + + // 차트 설정 초기화 + setChartConfig(element.chartConfig || {}); + + // 커스텀 메트릭 설정 초기화 + setCustomMetricConfig(element.customMetricConfig || { metrics: [] }); + } else if (!isOpen) { + // 사이드바 닫힐 때 초기화 + setCustomTitle(""); + setShowHeader(true); + setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 }); + setQueryResult(null); + setListConfig({ + viewMode: "table", + columns: [], + pageSize: 10, + enablePagination: true, + showHeader: true, + stripedRows: true, + compactMode: false, + cardColumns: 3, + }); + setChartConfig({}); + setCustomMetricConfig({ metrics: [] }); + } + }, [isOpen, element]); + + // Esc 키로 닫기 + useEffect(() => { + if (!isOpen) return; + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [isOpen, onClose]); + + // 데이터 소스 타입 변경 + const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { + if (type === "database") { + setDataSource({ + type: "database", + connectionType: "current", + refreshInterval: 0, + }); + } else { + setDataSource({ + type: "api", + method: "GET", + refreshInterval: 0, + }); + } + }, []); + + // 데이터 소스 업데이트 + const handleDataSourceUpdate = useCallback((updates: Partial) => { + setDataSource((prev) => ({ ...prev, ...updates })); + }, []); + + // 쿼리 테스트 결과 처리 + const handleQueryTest = useCallback( + (result: QueryResult) => { + setQueryResult(result); + + // 리스트 위젯: 쿼리 결과로 컬럼 자동 생성 + if (element?.subtype === "list-v2" && result.columns && result.columns.length > 0) { + const newColumns = result.columns.map((col: string, idx: number) => ({ + id: `col_${Date.now()}_${idx}`, + field: col, + label: col, + visible: true, + sortable: true, + filterable: false, + align: "left" as const, + })); + + setListConfig((prev) => ({ ...prev, columns: newColumns })); + } + }, + [element], + ); + + // 리스트 설정 변경 + const handleListConfigChange = useCallback((updates: Partial) => { + setListConfig((prev) => ({ ...prev, ...updates })); + }, []); + + // 차트 설정 변경 + const handleChartConfigChange = useCallback((config: ChartConfig) => { + setChartConfig(config); + }, []); + + // 커스텀 메트릭 설정 변경 + const handleCustomMetricConfigChange = useCallback((updates: Partial) => { + setCustomMetricConfig((prev) => ({ ...prev, ...updates })); + }, []); + + // 적용 + const handleApply = useCallback(() => { + if (!element) return; + + const updatedElement: DashboardElement = { + ...element, + customTitle: customTitle.trim() || undefined, + showHeader, + // 데이터 소스가 필요한 위젯만 dataSource 포함 + ...(needsDataSource(element.subtype) + ? { + dataSource, + } + : {}), + // 리스트 위젯 설정 + ...(element.subtype === "list-v2" + ? { + listConfig, + } + : {}), + // 차트 설정 (차트 타입이거나 차트 기능이 있는 위젯) + ...(element.type === "chart" || + element.subtype === "chart" || + ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype) + ? { + chartConfig, + } + : {}), + // 커스텀 메트릭 설정 + ...(element.subtype === "custom-metric-v2" + ? { + customMetricConfig, + } + : {}), + }; + + onApply(updatedElement); + onClose(); + }, [element, customTitle, showHeader, dataSource, listConfig, chartConfig, customMetricConfig, onApply, onClose]); + + if (!element) return null; + + const hasDataTab = needsDataSource(element.subtype); + const widgetIcon = getWidgetIcon(element.subtype); + const widgetTitle = getWidgetTitle(element.subtype); + + return ( +
+ {/* 헤더 */} +
+
+
+ {widgetIcon} +
+ {widgetTitle} 설정 +
+ +
+ + {/* 탭 영역 */} + + + + 일반 + + {hasDataTab && ( + + 데이터 + + )} + + + {/* 일반 탭 */} + +
+ {/* 위젯 제목 */} +
+ + setCustomTitle(e.target.value)} + placeholder={`기본 제목: ${element.title}`} + className="h-9 text-sm" + /> +

비워두면 기본 제목이 표시됩니다

+
+ + {/* 헤더 표시 */} +
+
+
+ +

위젯 상단 헤더를 표시합니다

+
+ +
+
+
+
+ + {/* 데이터 탭 */} + {hasDataTab && ( + +
+ {/* 데이터 소스 선택 */} +
+ + + handleDataSourceTypeChange(value as "database" | "api")} + className="w-full" + > + + + 데이터베이스 + + + REST API + + + + + + + + + + + + +
+ + {/* 위젯별 커스텀 섹션 */} + {element.subtype === "list-v2" && ( + + )} + + {/* 차트 설정 */} + {(element.type === "chart" || + element.subtype === "chart" || + ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes( + element.subtype, + )) && ( + + )} + + {/* 커스텀 메트릭 설정 */} + {element.subtype === "custom-metric-v2" && ( + + )} + + {/* 지도 설정 */} + {element.subtype === "map-summary-v2" && } + + {/* 리스크 알림 설정 */} + {element.subtype === "risk-alert-v2" && } +
+
+ )} +
+ + {/* 푸터 */} +
+ + +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx b/frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx new file mode 100644 index 00000000..cf6e38e7 --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import { ChartConfig, QueryResult, ChartDataSource } from "../types"; +import { Label } from "@/components/ui/label"; +import { ChartConfigPanel } from "../ChartConfigPanel"; + +interface ChartConfigSectionProps { + queryResult: QueryResult | null; + dataSource: ChartDataSource; + config: ChartConfig; + chartType?: string; + onConfigChange: (config: ChartConfig) => void; +} + +/** + * 차트 설정 섹션 + * - 차트 타입, 축 매핑, 스타일 설정 + */ +export function ChartConfigSection({ + queryResult, + dataSource, + config, + chartType, + onConfigChange, +}: ChartConfigSectionProps) { + // 쿼리 결과가 없으면 표시하지 않음 + if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { + return null; + } + + return ( +
+ + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx new file mode 100644 index 00000000..eca4b018 --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx @@ -0,0 +1,49 @@ +"use client"; + +import React from "react"; +import { CustomMetricConfig, QueryResult } from "../types"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; + +interface CustomMetricSectionProps { + queryResult: QueryResult | null; + config: CustomMetricConfig; + onConfigChange: (updates: Partial) => void; +} + +/** + * 통계 카드 설정 섹션 + * - 메트릭 설정, 아이콘, 색상 + * + * TODO: 상세 설정 UI 추가 필요 + */ +export function CustomMetricSection({ queryResult, config, onConfigChange }: CustomMetricSectionProps) { + // 쿼리 결과가 없으면 안내 메시지 표시 + if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { + return ( +
+ + + + + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + + +
+ ); + } + + return ( +
+ + + + + 통계 카드 상세 설정 UI는 추후 추가 예정입니다. + + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx new file mode 100644 index 00000000..8459716e --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; +import { ListWidgetConfig, QueryResult } from "../types"; +import { Label } from "@/components/ui/label"; +import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor"; +import { ListTableOptions } from "../widgets/list-widget/ListTableOptions"; + +interface ListWidgetSectionProps { + queryResult: QueryResult | null; + config: ListWidgetConfig; + onConfigChange: (updates: Partial) => void; +} + +/** + * 리스트 위젯 설정 섹션 + * - 컬럼 설정 + * - 테이블 옵션 + */ +export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) { + return ( +
+ {/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} + {queryResult && queryResult.columns.length > 0 && ( +
+ + +
+ )} + + {/* 테이블 옵션 */} + {config.columns.length > 0 && ( +
+ + +
+ )} +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx new file mode 100644 index 00000000..ae75f7cb --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; +import { QueryResult } from "../types"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; + +interface MapConfigSectionProps { + queryResult: QueryResult | null; +} + +/** + * 지도 위젯 설정 섹션 + * - 위도/경도 매핑 + * + * TODO: 상세 설정 UI 추가 필요 + */ +export function MapConfigSection({ queryResult }: MapConfigSectionProps) { + // 쿼리 결과가 없으면 안내 메시지 표시 + if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { + return ( +
+ + + + + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + + +
+ ); + } + + return ( +
+ + + + + 지도 상세 설정 UI는 추후 추가 예정입니다. + + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx b/frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx new file mode 100644 index 00000000..77c30d80 --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; +import { QueryResult } from "../types"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; + +interface RiskAlertSectionProps { + queryResult: QueryResult | null; +} + +/** + * 리스크 알림 위젯 설정 섹션 + * - 알림 설정 + * + * TODO: 상세 설정 UI 추가 필요 + */ +export function RiskAlertSection({ queryResult }: RiskAlertSectionProps) { + // 쿼리 결과가 없으면 안내 메시지 표시 + if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { + return ( +
+ + + + + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + + +
+ ); + } + + return ( +
+ + + + + 리스크 알림 상세 설정 UI는 추후 추가 예정입니다. + + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx deleted file mode 100644 index 5e7d9131..00000000 --- a/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types"; -import { X } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { DatabaseConfig } from "../data-sources/DatabaseConfig"; -import { ApiConfig } from "../data-sources/ApiConfig"; -import { QueryEditor } from "../QueryEditor"; -import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor"; -import { ListTableOptions } from "./list-widget/ListTableOptions"; - -interface ListWidgetConfigSidebarProps { - element: DashboardElement; - isOpen: boolean; - onClose: () => void; - onApply: (element: DashboardElement) => void; -} - -/** - * 리스트 위젯 설정 사이드바 - */ -export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) { - const [title, setTitle] = useState(element.title || "📋 리스트"); - const [dataSource, setDataSource] = useState( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [queryResult, setQueryResult] = useState(null); - const [listConfig, setListConfig] = useState( - element.listConfig || { - viewMode: "table", - columns: [], - pageSize: 10, - enablePagination: true, - showHeader: true, - stripedRows: true, - compactMode: false, - cardColumns: 3, - }, - ); - - // 사이드바 열릴 때 초기화 - useEffect(() => { - if (isOpen) { - setTitle(element.title || "📋 리스트"); - if (element.dataSource) { - setDataSource(element.dataSource); - } - if (element.listConfig) { - setListConfig(element.listConfig); - } - } - }, [isOpen, element]); - - // Esc 키로 닫기 - useEffect(() => { - if (!isOpen) return; - - const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("keydown", handleEsc); - return () => window.removeEventListener("keydown", handleEsc); - }, [isOpen, onClose]); - - // 데이터 소스 타입 변경 - const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { - if (type === "database") { - setDataSource({ - type: "database", - connectionType: "current", - refreshInterval: 0, - }); - } else { - setDataSource({ - type: "api", - method: "GET", - refreshInterval: 0, - }); - } - setQueryResult(null); - }, []); - - // 데이터 소스 업데이트 - const handleDataSourceUpdate = useCallback((updates: Partial) => { - setDataSource((prev) => ({ ...prev, ...updates })); - }, []); - - // 쿼리 실행 결과 처리 - const handleQueryTest = useCallback((result: QueryResult) => { - setQueryResult(result); - - // 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기) - const newColumns = result.columns.map((col, idx) => ({ - id: `col_${Date.now()}_${idx}`, - field: col, - label: col, - visible: true, - align: "left" as const, - })); - - setListConfig((prev) => ({ - ...prev, - columns: newColumns, - })); - }, []); - - // 컬럼 설정 변경 - const handleListConfigChange = useCallback((updates: Partial) => { - setListConfig((prev) => ({ ...prev, ...updates })); - }, []); - - // 적용 - const handleApply = useCallback(() => { - const updatedElement: DashboardElement = { - ...element, - title, - dataSource, - listConfig, - }; - - onApply(updatedElement); - }, [element, title, dataSource, listConfig, onApply]); - - // 저장 가능 여부 - const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field); - - return ( -
- {/* 헤더 */} -
-
-
- 📋 -
- 리스트 위젯 설정 -
- -
- - {/* 본문: 스크롤 가능 영역 */} -
- {/* 기본 설정 */} -
-
기본 설정
-
-
- setTitle(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - placeholder="리스트 이름" - className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background focus:ring-1 focus:outline-none" - /> -
-
-
- - {/* 데이터 소스 */} -
-
데이터 소스
- - handleDataSourceTypeChange(value as "database" | "api")} - className="w-full" - > - - - 데이터베이스 - - - REST API - - - - - - - - - - - - - - {/* 데이터 로드 상태 */} - {queryResult && ( -
-
- {queryResult.rows.length}개 데이터 로드됨 -
- )} -
- - {/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} - {queryResult && ( -
-
컬럼 설정
- -
- )} - - {/* 테이블 옵션 - 컬럼이 있을 때만 표시 */} - {listConfig.columns.length > 0 && ( -
-
테이블 옵션
- -
- )} -
- - {/* 푸터: 적용 버튼 */} -
- - -
-
- ); -} diff --git a/frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx deleted file mode 100644 index 3a72453a..00000000 --- a/frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx +++ /dev/null @@ -1,119 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { DashboardElement } from "../types"; -import { X } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface YardWidgetConfigSidebarProps { - element: DashboardElement; - isOpen: boolean; - onClose: () => void; - onApply: (updates: Partial) => void; -} - -export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) { - const [customTitle, setCustomTitle] = useState(element.customTitle || ""); - const [showHeader, setShowHeader] = useState(element.showHeader !== false); - - useEffect(() => { - if (isOpen) { - setCustomTitle(element.customTitle || ""); - setShowHeader(element.showHeader !== false); - } - }, [isOpen, element]); - - const handleApply = () => { - onApply({ - customTitle, - showHeader, - }); - onClose(); - }; - - return ( -
- {/* 헤더 */} -
-
-
- 🏗️ -
- 야드 관리 위젯 설정 -
- -
- - {/* 컨텐츠 */} -
-
- {/* 위젯 제목 */} -
-
위젯 제목
- setCustomTitle(e.target.value)} - placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)" - className="h-8 text-xs" - style={{ fontSize: "12px" }} - /> -

기본 제목: 야드 관리 3D

-
- - {/* 헤더 표시 */} -
-
헤더 표시
- setShowHeader(value === "show")} - className="flex items-center gap-3" - > -
- - -
-
- - -
-
-
-
-
- - {/* 푸터 */} -
- - -
-
- ); -} diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx deleted file mode 100644 index 6d341612..00000000 --- a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx +++ /dev/null @@ -1,516 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react"; -import { DatabaseConfig } from "../../data-sources/DatabaseConfig"; -import { ChartDataSource } from "../../types"; -import { ApiConfig } from "../../data-sources/ApiConfig"; -import { QueryEditor } from "../../QueryEditor"; -import { v4 as uuidv4 } from "uuid"; -import { cn } from "@/lib/utils"; - -interface CustomMetricConfigSidebarProps { - element: DashboardElement; - isOpen: boolean; - onClose: () => void; - onApply: (updates: Partial) => void; -} - -export default function CustomMetricConfigSidebar({ - element, - isOpen, - onClose, - onApply, -}: CustomMetricConfigSidebarProps) { - const [metrics, setMetrics] = useState(element.customMetricConfig?.metrics || []); - const [expandedMetric, setExpandedMetric] = useState(null); - const [queryColumns, setQueryColumns] = useState([]); - const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database"); - const [dataSource, setDataSource] = useState( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [draggedIndex, setDraggedIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); - const [customTitle, setCustomTitle] = useState(element.customTitle || element.title || ""); - const [showHeader, setShowHeader] = useState(element.showHeader !== false); - const [groupByMode, setGroupByMode] = useState(element.customMetricConfig?.groupByMode || false); - const [groupByDataSource, setGroupByDataSource] = useState( - element.customMetricConfig?.groupByDataSource, - ); - const [groupByQueryColumns, setGroupByQueryColumns] = useState([]); - - // 쿼리 실행 결과 처리 (일반 지표용) - const handleQueryTest = (result: any) => { - // QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } } - if (result.success && result.data?.columns) { - setQueryColumns(result.data.columns); - } - // ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] } - else if (result.columns && Array.isArray(result.columns)) { - setQueryColumns(result.columns); - } - // 오류 처리 - else { - setQueryColumns([]); - } - }; - - // 쿼리 실행 결과 처리 (그룹별 카드용) - const handleGroupByQueryTest = (result: any) => { - if (result.success && result.data?.columns) { - setGroupByQueryColumns(result.data.columns); - } else if (result.columns && Array.isArray(result.columns)) { - setGroupByQueryColumns(result.columns); - } else { - setGroupByQueryColumns([]); - } - }; - - // 메트릭 추가 - const addMetric = () => { - const newMetric = { - id: uuidv4(), - field: "", - label: "새 지표", - aggregation: "count" as const, - unit: "", - color: "gray" as const, - decimals: 1, - }; - setMetrics([...metrics, newMetric]); - setExpandedMetric(newMetric.id); - }; - - // 메트릭 삭제 - const deleteMetric = (id: string) => { - setMetrics(metrics.filter((m) => m.id !== id)); - if (expandedMetric === id) { - setExpandedMetric(null); - } - }; - - // 메트릭 업데이트 - const updateMetric = (id: string, field: string, value: any) => { - setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m))); - }; - - // 메트릭 순서 변경 - // 드래그 앤 드롭 핸들러 - const handleDragStart = (index: number) => { - setDraggedIndex(index); - }; - - const handleDragOver = (e: React.DragEvent, index: number) => { - e.preventDefault(); - setDragOverIndex(index); - }; - - const handleDrop = (e: React.DragEvent, dropIndex: number) => { - e.preventDefault(); - if (draggedIndex === null || draggedIndex === dropIndex) { - setDraggedIndex(null); - setDragOverIndex(null); - return; - } - - const newMetrics = [...metrics]; - const [draggedItem] = newMetrics.splice(draggedIndex, 1); - newMetrics.splice(dropIndex, 0, draggedItem); - - setMetrics(newMetrics); - setDraggedIndex(null); - setDragOverIndex(null); - }; - - const handleDragEnd = () => { - setDraggedIndex(null); - setDragOverIndex(null); - }; - - // 데이터 소스 업데이트 - const handleDataSourceUpdate = (updates: Partial) => { - const newDataSource = { ...dataSource, ...updates }; - setDataSource(newDataSource); - onApply({ dataSource: newDataSource }); - }; - - // 데이터 소스 타입 변경 - const handleDataSourceTypeChange = (type: "database" | "api") => { - setDataSourceType(type); - const newDataSource: ChartDataSource = - type === "database" - ? { type: "database", connectionType: "current", refreshInterval: 0 } - : { type: "api", method: "GET", refreshInterval: 0 }; - - setDataSource(newDataSource); - onApply({ dataSource: newDataSource }); - setQueryColumns([]); - }; - - // 그룹별 데이터 소스 업데이트 - const handleGroupByDataSourceUpdate = (updates: Partial) => { - const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource; - setGroupByDataSource(newDataSource); - }; - - // 저장 - const handleSave = () => { - onApply({ - customTitle: customTitle, - showHeader: showHeader, - customMetricConfig: { - groupByMode, - groupByDataSource: groupByMode ? groupByDataSource : undefined, - metrics, - }, - }); - }; - - if (!isOpen) return null; - - return ( -
- {/* 헤더 */} -
-
-
- 📊 -
- 커스텀 카드 설정 -
- -
- - {/* 본문: 스크롤 가능 영역 */} -
-
- {/* 헤더 설정 */} -
-
헤더 설정
-
- {/* 제목 입력 */} -
- - setCustomTitle(e.target.value)} - placeholder="위젯 제목을 입력하세요" - className="h-8 text-xs" - style={{ fontSize: "12px" }} - /> -
- - {/* 헤더 표시 여부 */} -
- - -
-
-
- - {/* 데이터 소스 타입 선택 */} -
-
데이터 소스 타입
-
- - -
-
- - {/* 데이터 소스 설정 */} - {dataSourceType === "database" ? ( - <> - - - - ) : ( - - )} - - {/* 일반 지표 설정 (항상 표시) */} -
-
-
일반 지표
- {queryColumns.length > 0 && ( - - )} -
- - {queryColumns.length === 0 ? ( -

먼저 쿼리를 실행하세요

- ) : ( -
- {metrics.length === 0 ? ( -

추가된 지표가 없습니다

- ) : ( - metrics.map((metric, index) => ( -
handleDragOver(e, index)} - onDrop={(e) => handleDrop(e, index)} - className={cn( - "rounded-md border bg-background p-2 transition-all", - draggedIndex === index && "opacity-50", - dragOverIndex === index && draggedIndex !== index && "border-primary border-2", - )} - > - {/* 헤더 */} -
-
handleDragStart(index)} - onDragEnd={handleDragEnd} - className="cursor-grab active:cursor-grabbing" - > - -
-
- - {metric.label || "새 지표"} - - {metric.aggregation.toUpperCase()} - -
-
- - {/* 설정 영역 */} - {expandedMetric === metric.id && ( -
- {/* 2열 그리드 레이아웃 */} -
- {/* 컬럼 */} -
- - -
- - {/* 집계 함수 */} -
- - -
- - {/* 단위 */} -
- - updateMetric(metric.id, "unit", e.target.value)} - className="h-6 w-full text-[10px]" - placeholder="건, %, km" - /> -
- - {/* 소수점 */} -
- - -
-
- - {/* 표시 이름 (전체 너비) */} -
- - updateMetric(metric.id, "label", e.target.value)} - className="h-6 w-full text-[10px]" - placeholder="라벨" - /> -
- - {/* 삭제 버튼 */} -
- -
-
- )} -
- )) - )} -
- )} -
- - {/* 그룹별 카드 생성 모드 (항상 표시) */} -
-
표시 모드
-
-
-
- -

- 쿼리 결과의 각 행을 개별 카드로 표시 -

-
- -
- {groupByMode && ( -
-

💡 사용 방법

-
    -
  • • 첫 번째 컬럼: 카드 제목
  • -
  • • 두 번째 컬럼: 카드 값
  • -
  • • 예: SELECT status, COUNT(*) FROM drivers GROUP BY status
  • -
  • 아래 별도 쿼리로 설정 (일반 지표와 독립적)
  • -
-
- )} -
-
- - {/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */} - {groupByMode && groupByDataSource && ( -
-
- 그룹별 카드 쿼리 -
- - -
- )} -
-
- - {/* 푸터 */} -
- - -
-
- ); -} From 085679a95a0ea0639d1b103b687a1c96e5877ec7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 12:10:46 +0900 Subject: [PATCH 06/76] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=BB=A8=ED=85=90=EC=B8=A0=EA=B0=80=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 9 ++----- .../admin/dashboard/DashboardDesigner.tsx | 22 ++++++++++----- .../admin/dashboard/WidgetConfigSidebar.tsx | 27 +++++++++++++++++-- .../admin/dashboard/widgets/ListWidget.tsx | 26 +++++++++--------- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 599718b9..90eec9ca 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -903,11 +903,6 @@ export function CanvasElement({
- ) : element.type === "widget" && element.subtype === "list-v2" ? ( - // 리스트 위젯 (다중 데이터 소스) - 승격 완료 -
- -
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? ( // 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
@@ -1014,8 +1009,8 @@ export function CanvasElement({ }} />
- ) : element.type === "widget" && element.subtype === "list" ? ( - // 리스트 위젯 렌더링 (구버전) + ) : element.type === "widget" && (element.subtype === "list" || element.subtype === "list-v2") ? ( + // 리스트 위젯 렌더링 (v1 & v2)
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index cdd080b6..3df5abdd 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -270,7 +270,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 요소 업데이트 const updateElement = useCallback((id: string, updates: Partial) => { - setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el))); + setElements((prev) => + prev.map((el) => { + if (el.id === id) { + return { ...el, ...updates }; + } + return el; + }), + ); }, []); // 요소 삭제 @@ -359,14 +366,17 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D (updatedElement: DashboardElement) => { // 현재 요소의 최신 상태를 가져와서 position과 size는 유지 const currentElement = elements.find((el) => el.id === updatedElement.id); + if (currentElement) { - // position과 size는 현재 상태 유지, 나머지만 업데이트 + // id, position, size 제거 후 나머지만 업데이트 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, position, size, ...updates } = updatedElement; const finalElement = { - ...updatedElement, - position: currentElement.position, - size: currentElement.size, + ...currentElement, + ...updates, }; - updateElement(finalElement.id, finalElement); + + updateElement(id, updates); // 사이드바도 최신 상태로 업데이트 setSidebarElement(finalElement); } diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index f4104e73..4f2b14a5 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -270,14 +270,28 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge const handleApply = useCallback(() => { if (!element) return; + // 다중 데이터 소스를 사용하는 위젯 체크 + const isMultiDataSourceWidget = + element.subtype === "map-summary-v2" || + element.subtype === "chart" || + element.subtype === "list-v2" || + element.subtype === "custom-metric-v2" || + element.subtype === "risk-alert-v2"; + const updatedElement: DashboardElement = { ...element, customTitle: customTitle.trim() || undefined, showHeader, - // 데이터 소스가 필요한 위젯만 dataSource 포함 + // 데이터 소스 처리 ...(needsDataSource(element.subtype) ? { dataSource, + // 다중 데이터 소스 위젯은 dataSources도 포함 + ...(isMultiDataSourceWidget + ? { + dataSources: element.dataSources || [], + } + : {}), } : {}), // 리스트 위젯 설정 @@ -291,7 +305,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge element.subtype === "chart" || ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype) ? { - chartConfig, + // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 + chartConfig: isMultiDataSourceWidget + ? { ...chartConfig, dataSources: element.dataSources || [] } + : chartConfig, + // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 + ...(isMultiDataSourceWidget + ? { + dataSources: element.dataSources || [], + } + : {}), } : {}), // 커스텀 메트릭 설정 diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 54237d26..8193aea4 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -39,7 +39,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { // 데이터 로드 useEffect(() => { const loadData = async () => { - if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return; + if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) { + return; + } setIsLoading(true); setError(null); @@ -168,8 +170,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { return (
-
-
데이터 로딩 중...
+
+
데이터 로딩 중...
); @@ -181,8 +183,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
⚠️
-
오류 발생
-
{error}
+
오류 발생
+
{error}
); @@ -194,8 +196,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
📋
-
리스트를 설정하세요
-
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
+
리스트를 설정하세요
+
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
); @@ -222,7 +224,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
{/* 제목 - 항상 표시 */}
-

{element.customTitle || element.title}

+

{element.customTitle || element.title}

{/* 테이블 뷰 */} @@ -251,7 +253,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { col.visible).length} - className="text-center text-muted-foreground" + className="text-muted-foreground text-center" > 데이터가 없습니다 @@ -281,7 +283,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {config.viewMode === "card" && (
{paginatedRows.length === 0 ? ( -
데이터가 없습니다
+
데이터가 없습니다
) : (
col.visible) .map((col) => (
-
{col.label || col.name}
+
{col.label || col.name}
{String(row[col.dataKey || col.field] ?? "")}
From 6d9c7ed7bf09dea54920bee1f215160b3b6df280 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 14:07:02 +0900 Subject: [PATCH 07/76] =?UTF-8?q?=EC=A7=80=EB=8F=84=EC=AA=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/WidgetConfigSidebar.tsx | 117 +++++++++++------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 4f2b14a5..4fb44de0 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -25,6 +25,7 @@ import { ChartConfigSection } from "./widget-sections/ChartConfigSection"; import { CustomMetricSection } from "./widget-sections/CustomMetricSection"; import { MapConfigSection } from "./widget-sections/MapConfigSection"; import { RiskAlertSection } from "./widget-sections/RiskAlertSection"; +import MultiDataSourceConfig from "@/components/admin/dashboard/data-sources/MultiDataSourceConfig"; interface WidgetConfigSidebarProps { element: DashboardElement | null; @@ -121,6 +122,9 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge refreshInterval: 0, }); + // 다중 데이터 소스 상태 추가 + const [dataSources, setDataSources] = useState(element?.dataSources || []); + // 쿼리 결과 const [queryResult, setQueryResult] = useState(null); @@ -148,6 +152,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setCustomTitle(element.customTitle || ""); setShowHeader(element.showHeader !== false); setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + setDataSources(element.dataSources || []); setQueryResult(null); // 리스트 위젯 설정 초기화 @@ -176,6 +181,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setCustomTitle(""); setShowHeader(true); setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 }); + setDataSources([]); setQueryResult(null); setListConfig({ viewMode: "table", @@ -228,6 +234,11 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setDataSource((prev) => ({ ...prev, ...updates })); }, []); + // 다중 데이터 소스 변경 핸들러 + const handleDataSourcesChange = useCallback((updatedSources: ChartDataSource[]) => { + setDataSources(updatedSources); + }, []); + // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback( (result: QueryResult) => { @@ -289,7 +300,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge // 다중 데이터 소스 위젯은 dataSources도 포함 ...(isMultiDataSourceWidget ? { - dataSources: element.dataSources || [], + dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], } : {}), } @@ -307,12 +318,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge ? { // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 chartConfig: isMultiDataSourceWidget - ? { ...chartConfig, dataSources: element.dataSources || [] } + ? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] } : chartConfig, // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 ...(isMultiDataSourceWidget ? { - dataSources: element.dataSources || [], + dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], } : {}), } @@ -327,7 +338,18 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge onApply(updatedElement); onClose(); - }, [element, customTitle, showHeader, dataSource, listConfig, chartConfig, customMetricConfig, onApply, onClose]); + }, [ + element, + customTitle, + showHeader, + dataSource, + dataSources, + listConfig, + chartConfig, + customMetricConfig, + onApply, + onClose, + ]); if (!element) return null; @@ -408,48 +430,57 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge {hasDataTab && (
- {/* 데이터 소스 선택 */} -
- + {/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */} + {!["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes( + element.subtype, + ) && ( +
+ - handleDataSourceTypeChange(value as "database" | "api")} - className="w-full" - > - - - 데이터베이스 - - - REST API - - + handleDataSourceTypeChange(value as "database" | "api")} + className="w-full" + > + + + 데이터베이스 + + + REST API + + - - - - + + + + - - - - -
+ + + + +
+ )} + + {/* 다중 데이터 소스 설정 */} + {["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes( + element.subtype, + ) && } {/* 위젯별 커스텀 섹션 */} {element.subtype === "list-v2" && ( From 1d9634ac41b486f3bbd6eb847f362bc529acf073 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 31 Oct 2025 18:21:03 +0900 Subject: [PATCH 08/76] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=9B=84=20=EB=B2=84=ED=8A=BC=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=89=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 버그 수정 - 화면 저장 성공 모달의 버튼 텍스트가 검은색으로 표시되던 문제 해결 - '화면 목록으로 이동' 버튼에 text-white 추가 - '메뉴에 할당' 버튼에 text-white 추가 - '화면 교체' 버튼에 text-white 추가 🎨 변경 내용 - bg-green-600 → bg-green-600 text-white - bg-blue-600 → bg-blue-600 text-white - bg-orange-600 → bg-orange-600 text-white 📝 관련 파일 - frontend/components/screen/MenuAssignmentModal.tsx ✅ 결과 - 모든 버튼 텍스트가 흰색으로 정상 표시됨 --- frontend/components/screen/MenuAssignmentModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index 6e1c16cb..dafa1319 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -380,7 +380,7 @@ export const MenuAssignmentModal: React.FC = ({ onClose(); } }} - className="bg-green-600 hover:bg-green-700" + className="bg-green-600 text-white hover:bg-green-700" > 화면 목록으로 이동 @@ -536,7 +536,7 @@ export const MenuAssignmentModal: React.FC = ({ +
+ + {config.filters && config.filters.length > 0 ? ( +
+ {config.filters.map((filter, index) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 값 입력 */} + updateFilter(index, "value", e.target.value)} + placeholder="값" + className="h-8 flex-1 text-xs" + /> + + {/* 삭제 버튼 */} + +
+ ))} +
+ ) : ( +

필터 없음 (전체 데이터 사용)

+ )} +
+ + {/* 2. 계산할 컬럼 선택 */} +
+ + +
+ + {/* 3. 계산 방식 선택 */} +
+ + +
+ + {/* 4. 카드 제목 */} +
+ + onConfigChange({ title: e.target.value })} + placeholder="예: 총 매출액" + className="h-9 text-xs" + /> +
+ + {/* 5. 표시 단위 (선택사항) */} +
+ + onConfigChange({ unit: e.target.value })} + placeholder="예: 원, 건, %" + className="h-9 text-xs" + /> +
+ + {/* 미리보기 */} + {config.valueColumn && config.aggregation && ( +
+

설정 미리보기

+ + {/* 필터 조건 표시 */} + {config.filters && config.filters.length > 0 && ( +
+

필터:

+ {config.filters.map((filter, idx) => ( +

+ · {filter.column} {filter.operator} "{filter.value}" +

+ ))} +
+ )} + + {/* 계산 표시 */} +

+ {config.title || "통계 제목"}: {config.aggregation?.toUpperCase()}({config.valueColumn}) + {config.unit ? ` ${config.unit}` : ""} +

+
+ )}
); } - diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 74869ef0..8483c82f 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -1,18 +1,46 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; -import { Button } from "@/components/ui/button"; -import { Loader2, RefreshCw } from "lucide-react"; -import { applyColumnMapping } from "@/lib/utils/columnMapping"; +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; import { getApiUrl } from "@/lib/utils/apiUrl"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; interface CustomMetricTestWidgetProps { element: DashboardElement; } +// 필터 적용 함수 +const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => { + if (!filters || filters.length === 0) return rows; + + return rows.filter((row) => { + return filters.every((filter) => { + const cellValue = String(row[filter.column] || ""); + const filterValue = filter.value; + + switch (filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case ">": + return parseFloat(cellValue) > parseFloat(filterValue); + case "<": + return parseFloat(cellValue) < parseFloat(filterValue); + case ">=": + return parseFloat(cellValue) >= parseFloat(filterValue); + case "<=": + return parseFloat(cellValue) <= parseFloat(filterValue); + case "contains": + return cellValue.includes(filterValue); + case "not_contains": + return !cellValue.includes(filterValue); + default: + return true; + } + }); + }); +}; + // 집계 함수 실행 const calculateMetric = (rows: any[], field: string, aggregation: string): number => { if (rows.length === 0) return 0; @@ -38,372 +66,138 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe } }; -// 색상 스타일 매핑 (차분한 색상) -const colorMap = { - indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - green: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, -}; - -/** - * 통계 카드 위젯 (다중 데이터 소스 지원) - * - 여러 REST API 연결 가능 - * - 여러 Database 연결 가능 - * - REST API + Database 혼합 가능 - * - 데이터 자동 병합 후 집계 - */ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) { - const [metrics, setMetrics] = useState([]); - const [groupedCards, setGroupedCards] = useState>([]); - const [loading, setLoading] = useState(false); + const [value, setValue] = useState(0); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lastRefreshTime, setLastRefreshTime] = useState(null); - const [selectedMetric, setSelectedMetric] = useState(null); - const [isDetailOpen, setIsDetailOpen] = useState(false); - // console.log("🧪 CustomMetricTestWidget 렌더링!", element); + const config = element?.customMetricConfig; - const dataSources = useMemo(() => { - return element?.dataSources || element?.chartConfig?.dataSources; - }, [element?.dataSources, element?.chartConfig?.dataSources]); + console.log("📊 [CustomMetricTestWidget] 렌더링:", { + element, + config, + dataSource: element?.dataSource, + }); - // 🆕 그룹별 카드 모드 체크 - const isGroupByMode = element?.customMetricConfig?.groupByMode || false; + useEffect(() => { + loadData(); - // 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션 - const metricConfig = useMemo(() => { - return ( - element?.customMetricConfig?.metrics || [ - { - label: "총 개수", - field: "id", - aggregation: "count", - color: "indigo", - }, - ] - ); - }, [element?.customMetricConfig?.metrics]); + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [element]); - // 🆕 그룹별 카드 데이터 로드 (원본에서 복사) - const loadGroupByData = useCallback(async () => { - const groupByDS = element?.customMetricConfig?.groupByDataSource; - if (!groupByDS) return; - - const dataSourceType = groupByDS.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!groupByDS.query) return; - - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(groupByDS.query); - - if (result && result.rows) { - const rows = result.rows; - if (rows.length > 0) { - const columns = result.columns || Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!groupByDS.endpoint) return; - - // REST API 호출 (백엔드 프록시 사용) - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: groupByDS.endpoint, - method: "GET", - headers: (groupByDS as any).headers || {}, - }), - }); - const result = await response.json(); - - if (result.success && result.data) { - let rows: any[] = []; - if (Array.isArray(result.data)) { - rows = result.data; - } else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } else { - rows = [result.data]; - } - - if (rows.length > 0) { - const columns = Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - }, [element?.customMetricConfig?.groupByDataSource]); - - // 다중 데이터 소스 로딩 - const loadMultipleDataSources = useCallback(async () => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; - - if (!dataSources || dataSources.length === 0) { - // console.log("⚠️ 데이터 소스가 없습니다."); - return; - } - - // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); - setLoading(true); - setError(null); - - try { - // 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리) - const results = await Promise.allSettled( - dataSources.map(async (source, sourceIndex) => { - try { - // console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`); - - let rows: any[] = []; - if (source.type === "api") { - rows = await loadRestApiData(source); - } else if (source.type === "database") { - rows = await loadDatabaseData(source); - } - - // console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`); - - return { - sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, - sourceIndex: sourceIndex, - rows: rows, - }; - } catch (err: any) { - console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); - return { - sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, - sourceIndex: sourceIndex, - rows: [], - }; - } - }), - ); - - // console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`); - - // 각 데이터 소스별로 메트릭 생성 - const allMetrics: any[] = []; - const colors = ["indigo", "green", "blue", "purple", "orange", "gray"]; - - results.forEach((result) => { - if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) { - return; - } - - const { sourceName, rows } = result.value; - - // 🎯 간단한 쿼리도 잘 작동하도록 개선된 로직 - if (rows.length > 0) { - const firstRow = rows[0]; - const columns = Object.keys(firstRow); - - // 숫자 컬럼 찾기 - const numericColumns = columns.filter((col) => { - const value = firstRow[col]; - return typeof value === "number" || !isNaN(Number(value)); - }); - - // 문자열 컬럼 찾기 - const stringColumns = columns.filter((col) => !numericColumns.includes(col)); - - // 🎯 케이스 0: 1행인데 숫자 컬럼이 여러 개 → 각 컬럼을 별도 카드로 - if (rows.length === 1 && numericColumns.length > 1) { - // 예: SELECT COUNT(*) AS 전체, SUM(...) AS 배송중, ... - numericColumns.forEach((col) => { - allMetrics.push({ - label: col, // 컬럼명이 라벨 - value: Number(firstRow[col]) || 0, - field: col, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [firstRow], - }); - }); - } - // 🎯 케이스 1: 컬럼이 2개 (라벨 + 값) → 가장 간단한 형태 - else if (columns.length === 2) { - const labelCol = columns[0]; - const valueCol = columns[1]; - - rows.forEach((row) => { - allMetrics.push({ - label: String(row[labelCol] || ""), - value: Number(row[valueCol]) || 0, - field: valueCol, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [row], - }); - }); - } - // 🎯 케이스 2: 숫자 컬럼이 1개 이상 있음 → 집계된 데이터 - else if (numericColumns.length > 0) { - rows.forEach((row, index) => { - // 라벨: 첫 번째 문자열 컬럼 (없으면 첫 번째 컬럼) - const labelField = stringColumns[0] || columns[0]; - const label = String(row[labelField] || `항목 ${index + 1}`); - - // 값: 첫 번째 숫자 컬럼 - const valueField = numericColumns[0]; - const value = Number(row[valueField]) || 0; - - allMetrics.push({ - label: label, - value: value, - field: valueField, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [row], - }); - }); - } - // 🎯 케이스 3: 숫자 컬럼이 없음 → 마지막 컬럼 기준으로 카운트 - else { - const aggregateField = columns[columns.length - 1]; - const countMap = new Map(); - - rows.forEach((row) => { - const value = String(row[aggregateField] || "기타"); - countMap.set(value, (countMap.get(value) || 0) + 1); - }); - - countMap.forEach((count, label) => { - allMetrics.push({ - label: label, - value: count, - field: aggregateField, - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows.filter((row) => String(row[aggregateField]) === label), - }); - }); - - // 전체 개수도 추가 - allMetrics.push({ - label: "전체", - value: rows.length, - field: "count", - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, - }); - } - - } - // 🎯 행이 많을 때도 간단하게 처리 - else if (rows.length > 100) { - // 행이 많으면 총 개수만 표시 - // console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`); - - const firstRow = rows[0]; - const columns = Object.keys(firstRow); - - // 데이터 소스에서 선택된 컬럼 가져오기 - const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find( - (ds) => ds.name === sourceName || (result.status === "fulfilled" && ds.id === result.value?.sourceIndex.toString()), - ); - const selectedColumns = dataSourceConfig?.selectedColumns || []; - - // 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시 - const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns; - - // console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow); - - // 각 컬럼별 고유값 개수 - columnsToShow.forEach((col) => { - // 해당 컬럼이 실제로 존재하는지 확인 - if (!columns.includes(col)) { - console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`); - return; - } - - const uniqueValues = new Set(rows.map((row) => row[col])); - const uniqueCount = uniqueValues.size; - - // console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`); - - allMetrics.push({ - label: `${sourceName} - ${col} (고유값)`, - value: uniqueCount, - field: col, - aggregation: "distinct", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, // 원본 데이터 저장 - }); - }); - - // 총 행 개수 - allMetrics.push({ - label: `${sourceName} - 총 개수`, - value: rows.length, - field: "count", - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, // 원본 데이터 저장 - }); - } - }); - - // console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`); - setMetrics(allMetrics); - setLastRefreshTime(new Date()); - } catch (err) { - setError(err instanceof Error ? err.message : "데이터 로딩 실패"); - } finally { - setLoading(false); - } - }, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]); - - // 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭) - const loadAllData = useCallback(async () => { + const loadData = async () => { try { setLoading(true); setError(null); - // 그룹별 카드 데이터 로드 - if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { - await loadGroupByData(); - } + const dataSourceType = element?.dataSource?.type; - // 일반 메트릭 데이터 로드 - if (dataSources && dataSources.length > 0) { - await loadMultipleDataSources(); + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: (element.dataSource as any).connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + let rows = result.data.rows; + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: (element.dataSource as any).method || "GET", + url: element.dataSource.endpoint, + headers: (element.dataSource as any).headers || {}, + body: (element.dataSource as any).body, + authType: (element.dataSource as any).authType, + authConfig: (element.dataSource as any).authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + let rows: any[] = []; + + // API 응답 데이터 구조 확인 및 처리 + if (Array.isArray(result.data)) { + rows = result.data; + } else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } else { + rows = [result.data]; + } + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error("API 응답 형식 오류"); + } } } catch (err) { console.error("데이터 로드 실패:", err); @@ -411,256 +205,11 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } finally { setLoading(false); } - }, [ - isGroupByMode, - element?.customMetricConfig?.groupByDataSource, - dataSources, - loadGroupByData, - loadMultipleDataSources, - ]); - - // 수동 새로고침 핸들러 - const handleManualRefresh = useCallback(() => { - // console.log("🔄 수동 새로고침 버튼 클릭"); - loadAllData(); - }, [loadAllData]); - - // XML 데이터 파싱 - const parseXmlData = (xmlText: string): any[] => { - // console.log("🔍 XML 파싱 시작"); - try { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlText, "text/xml"); - - const records = xmlDoc.getElementsByTagName("record"); - const result: any[] = []; - - for (let i = 0; i < records.length; i++) { - const record = records[i]; - const obj: any = {}; - - for (let j = 0; j < record.children.length; j++) { - const child = record.children[j]; - obj[child.tagName] = child.textContent || ""; - } - - result.push(obj); - } - - // console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`); - return result; - } catch (error) { - console.error("❌ XML 파싱 실패:", error); - throw new Error("XML 파싱 실패"); - } }; - // 텍스트/CSV 데이터 파싱 - const parseTextData = (text: string): any[] => { - // console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500)); - - // XML 감지 - if (text.trim().startsWith("")) { - // console.log("📄 XML 형식 감지"); - return parseXmlData(text); - } - - // CSV 파싱 - // console.log("📄 CSV 형식으로 파싱 시도"); - const lines = text.trim().split("\n"); - if (lines.length === 0) return []; - - const headers = lines[0].split(",").map((h) => h.trim()); - const result: any[] = []; - - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(","); - const obj: any = {}; - - headers.forEach((header, index) => { - obj[header] = values[index]?.trim() || ""; - }); - - result.push(obj); - } - - // console.log(`✅ CSV 파싱 완료: ${result.length}개 행`); - return result; - }; - - // REST API 데이터 로딩 - const loadRestApiData = async (source: ChartDataSource): Promise => { - if (!source.endpoint) { - throw new Error("API endpoint가 없습니다."); - } - - const params = new URLSearchParams(); - - // queryParams 배열 또는 객체 처리 - if (source.queryParams) { - if (Array.isArray(source.queryParams)) { - source.queryParams.forEach((param: any) => { - if (param.key && param.value) { - params.append(param.key, String(param.value)); - } - }); - } else { - Object.entries(source.queryParams).forEach(([key, value]) => { - if (key && value) { - params.append(key, String(value)); - } - }); - } - } - - // console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params)); - - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: source.endpoint, - method: "GET", - headers: source.headers || {}, - queryParams: Object.fromEntries(params), - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("❌ API 호출 실패:", { - status: response.status, - statusText: response.statusText, - body: errorText.substring(0, 500), - }); - throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`); - } - - const result = await response.json(); - // console.log("✅ API 응답:", result); - - if (!result.success) { - console.error("❌ API 실패:", result); - throw new Error(result.message || result.error || "외부 API 호출 실패"); - } - - let processedData = result.data; - - // 텍스트/XML 데이터 처리 - if (typeof processedData === "string") { - // console.log("📄 텍스트 형식 데이터 감지"); - processedData = parseTextData(processedData); - } else if (processedData && typeof processedData === "object" && processedData.text) { - // console.log("📄 래핑된 텍스트 데이터 감지"); - processedData = parseTextData(processedData.text); - } - - // JSON Path 처리 - if (source.jsonPath) { - const paths = source.jsonPath.split("."); - for (const path of paths) { - if (processedData && typeof processedData === "object" && path in processedData) { - processedData = processedData[path]; - } else { - throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`); - } - } - } else if (!Array.isArray(processedData) && typeof processedData === "object") { - // JSON Path 없으면 자동으로 배열 찾기 - // console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도"); - const arrayKeys = ["data", "items", "result", "records", "rows", "list"]; - - for (const key of arrayKeys) { - if (Array.isArray(processedData[key])) { - // console.log(`✅ 배열 발견: ${key}`); - processedData = processedData[key]; - break; - } - } - } - - const rows = Array.isArray(processedData) ? processedData : [processedData]; - - // 컬럼 매핑 적용 - return applyColumnMapping(rows, source.columnMapping); - }; - - // Database 데이터 로딩 - const loadDatabaseData = async (source: ChartDataSource): Promise => { - if (!source.query) { - throw new Error("SQL 쿼리가 없습니다."); - } - - let rows: any[] = []; - - if (source.connectionType === "external" && source.externalConnectionId) { - // 외부 DB - const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); - const externalResult = await ExternalDbConnectionAPI.executeQuery( - parseInt(source.externalConnectionId), - source.query, - ); - - if (!externalResult.success || !externalResult.data) { - throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); - } - - const resultData = externalResult.data as unknown as { - rows: Record[]; - }; - - rows = resultData.rows; - } else { - // 현재 DB - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(source.query); - - rows = result.rows; - } - - // 컬럼 매핑 적용 - return applyColumnMapping(rows, source.columnMapping); - }; - - // 초기 로드 (🆕 loadAllData 사용) - useEffect(() => { - if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) { - loadAllData(); - } - }, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]); - - // 자동 새로고침 (🆕 loadAllData 사용) - useEffect(() => { - if (!dataSources || dataSources.length === 0) return; - - const intervals = dataSources - .map((ds) => ds.refreshInterval) - .filter((interval): interval is number => typeof interval === "number" && interval > 0); - - if (intervals.length === 0) return; - - const minInterval = Math.min(...intervals); - // console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`); - - const intervalId = setInterval(() => { - // console.log("🔄 자동 새로고침 실행"); - loadAllData(); - }, minInterval * 1000); - - return () => { - // console.log("⏹️ 자동 새로고침 정리"); - clearInterval(intervalId); - }; - }, [dataSources, loadAllData]); - - // renderMetricCard 함수 제거 - 인라인으로 렌더링 - - // 로딩 상태 (원본 스타일) if (loading) { return ( -
+

데이터 로딩 중...

@@ -669,14 +218,13 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg ); } - // 에러 상태 (원본 스타일) if (error) { return ( -
+

⚠️ {error}

+ ); + } + + // 소수점 자릿수 (기본: 0) + const decimals = config?.decimals ?? 0; + const formattedValue = value.toFixed(decimals); + + // 통계 카드 렌더링 + return ( +
+
+ {/* 제목 */} +
{config?.title || "통계"}
+ + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}} +
+ + {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
+ 필터: {config.filters.length}개 적용됨 +
+ )} +
); } diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index b284aa14..3f3538b4 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -8,6 +8,39 @@ interface CustomMetricWidgetProps { element?: DashboardElement; } +// 필터 적용 함수 +const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => { + if (!filters || filters.length === 0) return rows; + + return rows.filter((row) => { + return filters.every((filter) => { + const cellValue = String(row[filter.column] || ""); + const filterValue = filter.value; + + switch (filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case ">": + return parseFloat(cellValue) > parseFloat(filterValue); + case "<": + return parseFloat(cellValue) < parseFloat(filterValue); + case ">=": + return parseFloat(cellValue) >= parseFloat(filterValue); + case "<=": + return parseFloat(cellValue) <= parseFloat(filterValue); + case "contains": + return cellValue.includes(filterValue); + case "not_contains": + return !cellValue.includes(filterValue); + default: + return true; + } + }); + }); +}; + // 집계 함수 실행 const calculateMetric = (rows: any[], field: string, aggregation: string): number => { if (rows.length === 0) return 0; @@ -33,22 +66,12 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe } }; -// 색상 스타일 매핑 -const colorMap = { - indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - green: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, -}; - export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) { - const [metrics, setMetrics] = useState([]); - const [groupedCards, setGroupedCards] = useState>([]); + const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const isGroupByMode = element?.customMetricConfig?.groupByMode || false; + + const config = element?.customMetricConfig; useEffect(() => { loadData(); @@ -64,14 +87,111 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) setLoading(true); setError(null); - // 그룹별 카드 데이터 로드 - if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { - await loadGroupByData(); - } + const dataSourceType = element?.dataSource?.type; - // 일반 지표 데이터 로드 - if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) { - await loadMetricsData(); + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: (element.dataSource as any).connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + let rows = result.data.rows; + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: (element.dataSource as any).method || "GET", + url: element.dataSource.endpoint, + headers: (element.dataSource as any).headers || {}, + body: (element.dataSource as any).body, + authType: (element.dataSource as any).authType, + authConfig: (element.dataSource as any).authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + let rows: any[] = []; + + // API 응답 데이터 구조 확인 및 처리 + if (Array.isArray(result.data)) { + rows = result.data; + } else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } else { + rows = [result.data]; + } + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error("API 응답 형식 오류"); + } } } catch (err) { console.error("데이터 로드 실패:", err); @@ -81,221 +201,6 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } }; - // 그룹별 카드 데이터 로드 - const loadGroupByData = async () => { - const groupByDS = element?.customMetricConfig?.groupByDataSource; - if (!groupByDS) return; - - const dataSourceType = groupByDS.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!groupByDS.query) return; - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: groupByDS.query, - connectionType: groupByDS.connectionType || "current", - connectionId: (groupByDS as any).connectionId, - }), - }); - - if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패"); - - const result = await response.json(); - - if (result.success && result.data?.rows) { - const rows = result.data.rows; - if (rows.length > 0) { - const columns = result.data.columns || Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!groupByDS.endpoint) return; - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - method: (groupByDS as any).method || "GET", - url: groupByDS.endpoint, - headers: (groupByDS as any).headers || {}, - body: (groupByDS as any).body, - authType: (groupByDS as any).authType, - authConfig: (groupByDS as any).authConfig, - }), - }); - - if (!response.ok) throw new Error("그룹별 카드 API 호출 실패"); - - const result = await response.json(); - - if (result.success && result.data) { - let rows: any[] = []; - if (Array.isArray(result.data)) { - rows = result.data; - } else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } else { - rows = [result.data]; - } - - if (rows.length > 0) { - const columns = Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - }; - - // 일반 지표 데이터 로드 - const loadMetricsData = async () => { - const dataSourceType = element?.dataSource?.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!element?.dataSource?.query) { - setMetrics([]); - return; - } - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: element.dataSource.query, - connectionType: element.dataSource.connectionType || "current", - connectionId: (element.dataSource as any).connectionId, - }), - }); - - if (!response.ok) throw new Error("데이터 로딩 실패"); - - const result = await response.json(); - - if (result.success && result.data?.rows) { - const rows = result.data.rows; - - const calculatedMetrics = - element.customMetricConfig?.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; - }) || []; - - setMetrics(calculatedMetrics); - } else { - throw new Error(result.message || "데이터 로드 실패"); - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!element?.dataSource?.endpoint) { - setMetrics([]); - return; - } - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - method: (element.dataSource as any).method || "GET", - url: element.dataSource.endpoint, - headers: (element.dataSource as any).headers || {}, - body: (element.dataSource as any).body, - authType: (element.dataSource as any).authType, - authConfig: (element.dataSource as any).authConfig, - }), - }); - - if (!response.ok) throw new Error("API 호출 실패"); - - const result = await response.json(); - - if (result.success && result.data) { - // API 응답 데이터 구조 확인 및 처리 - let rows: any[] = []; - - // result.data가 배열인 경우 - if (Array.isArray(result.data)) { - rows = result.data; - } - // result.data.results가 배열인 경우 (일반적인 API 응답 구조) - else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } - // result.data.items가 배열인 경우 - else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } - // result.data.data가 배열인 경우 - else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } - // 그 외의 경우 단일 객체를 배열로 래핑 - else { - rows = [result.data]; - } - - const calculatedMetrics = - element.customMetricConfig?.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; - }) || []; - - setMetrics(calculatedMetrics); - } else { - throw new Error("API 응답 형식 오류"); - } - } - }; - if (loading) { return (
@@ -323,103 +228,64 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) ); } - // 데이터 소스 체크 - const hasMetricsDataSource = + // 설정 체크 + const hasDataSource = (element?.dataSource?.type === "database" && element?.dataSource?.query) || (element?.dataSource?.type === "api" && element?.dataSource?.endpoint); - const hasGroupByDataSource = - isGroupByMode && - element?.customMetricConfig?.groupByDataSource && - ((element.customMetricConfig.groupByDataSource.type === "database" && - element.customMetricConfig.groupByDataSource.query) || - (element.customMetricConfig.groupByDataSource.type === "api" && - element.customMetricConfig.groupByDataSource.endpoint)); + const hasConfig = config?.valueColumn && config?.aggregation; - const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0; - - // 둘 다 없으면 빈 화면 표시 - const shouldShowEmpty = - (!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource); - - if (shouldShowEmpty) { + // 설정이 없으면 안내 화면 + if (!hasDataSource || !hasConfig) { return (
-

사용자 커스텀 카드

+

통계 카드

-

📊 맞춤형 지표 위젯

+

📊 단일 통계 위젯

    -
  • • SQL 쿼리로 데이터를 불러옵니다
  • -
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • -
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • -
  • • 사용자 정의 단위 설정 가능
  • -
  • - • 그룹별 카드 생성 모드로 간편하게 사용 가능 -
  • +
  • • 데이터 소스에서 쿼리를 실행합니다
  • +
  • • 필터 조건으로 데이터를 필터링합니다
  • +
  • • 선택한 컬럼에 집계 함수를 적용합니다
  • +
  • • COUNT, SUM, AVG, MIN, MAX 지원

⚙️ 설정 방법

-

- {isGroupByMode - ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" - : "SQL 쿼리를 입력하고 지표를 추가하세요"} -

- {isGroupByMode &&

💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값

} +

1. 데이터 탭에서 쿼리 실행

+

2. 필터 조건 추가 (선택사항)

+

3. 계산 컬럼 및 방식 선택

+

4. 제목 및 단위 입력

); } - // 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로) - // 실제 측정된 1칸 높이: 119px - const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진) + // 소수점 자릿수 (기본: 0) + const decimals = config?.decimals ?? 0; + const formattedValue = value.toFixed(decimals); + // 통계 카드 렌더링 return ( -
- {/* 그룹별 카드 (활성화 시) */} - {isGroupByMode && - groupedCards.map((card, index) => { - // 색상 순환 (6가지 색상) - const colorKeys = Object.keys(colorMap) as Array; - const colorKey = colorKeys[index % colorKeys.length]; - const colors = colorMap[colorKey]; +
+
+ {/* 제목 */} +
{config?.title || "통계"}
- return ( -
-
{card.label}
-
{card.value.toLocaleString()}
-
- ); - })} + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}} +
- {/* 일반 지표 카드 (항상 표시) */} - {metrics.map((metric) => { - const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; - const formattedValue = metric.calculatedValue.toFixed(metric.decimals); - - return ( -
-
{metric.label}
-
- {formattedValue} - {metric.unit} -
+ {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
+ 필터: {config.filters.length}개 적용됨
- ); - })} + )} +
); } From 8e9daf5b22a15ed159db11287467d223dde97b1f Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 09:58:04 +0900 Subject: [PATCH 11/76] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=9E=90=EB=8F=99=20=EB=8B=AB=EA=B8=B0=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditModal: 저장 완료 후 자동으로 닫히고 부모 테이블 새로고침 - buttonActions.ts: 저장 성공 후 closeEditModal 이벤트 발생 - InteractiveScreenViewerDynamic: onSave prop 추가하여 EditModal 연동 - InteractiveDataTable: EditModal 열 때 onSave 콜백으로 loadData 전달 - 두 가지 시나리오 모두 지원: 1. InteractiveScreenViewerDynamic 버튼의 onSave 호출 2. DynamicComponentRenderer 버튼의 buttonActions.ts 처리 --- frontend/components/screen/EditModal.tsx | 63 ++- .../screen/InteractiveDataTable.tsx | 26 +- .../screen/InteractiveScreenViewerDynamic.tsx | 17 +- frontend/components/screen/ScreenDesigner.tsx | 38 +- frontend/lib/utils/buttonActions.ts | 6 +- 화면관리_및_테이블관리_개선사항_목록.md | 386 ++++++++++++++++++ 6 files changed, 481 insertions(+), 55 deletions(-) create mode 100644 화면관리_및_테이블관리_개선사항_목록.md diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2d3fb513..76363e4f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -102,13 +102,6 @@ export const EditModal: React.FC = ({ className }) => { useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { const { screenId, title, description, modalSize, editData, onSave } = event.detail; - console.log("🚀 EditModal 열기 이벤트 수신:", { - screenId, - title, - description, - modalSize, - editData, - }); setModalState({ isOpen: true, @@ -126,7 +119,16 @@ export const EditModal: React.FC = ({ className }) => { }; const handleCloseEditModal = () => { - console.log("🚪 EditModal 닫기 이벤트 수신"); + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } + } + + // 모달 닫기 handleClose(); }; @@ -137,7 +139,7 @@ export const EditModal: React.FC = ({ className }) => { window.removeEventListener("openEditModal", handleOpenEditModal as EventListener); window.removeEventListener("closeEditModal", handleCloseEditModal); }; - }, []); + }, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조 // 화면 데이터 로딩 useEffect(() => { @@ -211,12 +213,6 @@ export const EditModal: React.FC = ({ className }) => { } try { - console.log("💾 수정 저장 시작:", { - tableName: screenData.screenInfo.tableName, - formData, - originalData, - }); - // 변경된 필드만 추출 const changedData: Record = {}; Object.keys(formData).forEach((key) => { @@ -225,26 +221,33 @@ export const EditModal: React.FC = ({ className }) => { } }); - console.log("📝 변경된 필드:", changedData); - if (Object.keys(changedData).length === 0) { toast.info("변경된 내용이 없습니다."); handleClose(); return; } + // 기본키 확인 (id 또는 첫 번째 키) + const recordId = originalData.id || Object.values(originalData)[0]; + // UPDATE 액션 실행 - const response = await dynamicFormApi.updateData(screenData.screenInfo.tableName, { - ...originalData, // 원본 데이터 (WHERE 조건용) - ...changedData, // 변경된 데이터만 - }); + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalData, + changedData, + screenData.screenInfo.tableName, + ); if (response.success) { toast.success("데이터가 수정되었습니다."); - // 부모 컴포넌트의 onSave 콜백 실행 + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { - modalState.onSave(); + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } } handleClose(); @@ -335,16 +338,10 @@ export const EditModal: React.FC = ({ className }) => { allComponents={screenData.components} formData={formData} onFormDataChange={(fieldName, value) => { - console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - console.log("📝 EditModal 업데이트된 formData:", newFormData); - return newFormData; - }); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); }} screenInfo={{ id: modalState.screenId!, diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index b54df6ad..17662cac 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -769,7 +769,7 @@ export const InteractiveDataTable: React.FC = ({ setShowSaveModal(true); }, [getDisplayColumns, generateAutoValue, component.addModalConfig]); - // 데이터 수정 핸들러 (SaveModal 사용) + // 데이터 수정 핸들러 (EditModal 사용) const handleEditData = useCallback(() => { if (selectedRows.size !== 1) return; @@ -793,17 +793,25 @@ export const InteractiveDataTable: React.FC = ({ initialData[col.columnName] = selectedRowData[col.columnName] || ""; }); - setEditFormData(initialData); - setEditingRowData(selectedRowData); - // 수정 모달 설정에서 제목과 설명 가져오기 - const editModalTitle = component.editModalConfig?.title || ""; + const editModalTitle = component.editModalConfig?.title || "데이터 수정"; const editModalDescription = component.editModalConfig?.description || ""; - console.log("📝 수정 모달 설정:", { editModalTitle, editModalDescription }); - - setShowEditModal(true); - }, [selectedRows, data, getDisplayColumns, component.editModalConfig]); + // 전역 EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId, + title: editModalTitle, + description: editModalDescription, + modalSize: "lg", + editData: initialData, + onSave: () => { + loadData(); // 테이블 데이터 새로고침 + }, + }, + }); + window.dispatchEvent(event); + }, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]); // 수정 폼 데이터 변경 핸들러 const handleEditFormChange = useCallback((columnName: string, value: any) => { diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 7ed39353..e85aab58 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -38,6 +38,7 @@ interface InteractiveScreenViewerProps { id: number; tableName?: string; }; + onSave?: () => Promise; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -47,6 +48,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName, user } = useAuth(); @@ -204,8 +206,7 @@ export const InteractiveScreenViewerDynamic: React.FC { - // 화면 닫기 로직 (필요시 구현) - console.log("🚪 화면 닫기 요청"); + // buttonActions.ts가 이미 처리함 }} /> ); @@ -299,6 +300,18 @@ export const InteractiveScreenViewerDynamic: React.FC { + // EditModal에서 전달된 onSave가 있으면 우선 사용 (수정 모달) + if (onSave) { + try { + await onSave(); + } catch (error) { + console.error("저장 오류:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + return; + } + + // 일반 저장 액션 (신규 생성) if (!screenInfo?.tableName) { toast.error("테이블명이 설정되지 않았습니다."); return; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 5a11f325..51364429 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -381,19 +381,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 실행취소 const undo = useCallback(() => { - if (historyIndex > 0) { - setHistoryIndex((prev) => prev - 1); - setLayout(history[historyIndex - 1]); - } - }, [history, historyIndex]); + setHistoryIndex((prevIndex) => { + if (prevIndex > 0) { + const newIndex = prevIndex - 1; + setHistory((prevHistory) => { + if (prevHistory[newIndex]) { + setLayout(prevHistory[newIndex]); + } + return prevHistory; + }); + return newIndex; + } + return prevIndex; + }); + }, []); // 다시실행 const redo = useCallback(() => { - if (historyIndex < history.length - 1) { - setHistoryIndex((prev) => prev + 1); - setLayout(history[historyIndex + 1]); - } - }, [history, historyIndex]); + setHistoryIndex((prevIndex) => { + let newIndex = prevIndex; + setHistory((prevHistory) => { + if (prevIndex < prevHistory.length - 1) { + newIndex = prevIndex + 1; + if (prevHistory[newIndex]) { + setLayout(prevHistory[newIndex]); + } + } + return prevHistory; + }); + return newIndex; + }); + }, []); // 컴포넌트 속성 업데이트 const updateComponentProperty = useCallback( diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4c376b09..8b805d93 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -234,9 +234,13 @@ export class ButtonActionExecutor { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); } - // 테이블과 플로우 모두 새로고침 + // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); context.onFlowRefresh?.(); + + // 저장 성공 후 EditModal 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeEditModal")); + return true; } catch (error) { console.error("저장 오류:", error); diff --git a/화면관리_및_테이블관리_개선사항_목록.md b/화면관리_및_테이블관리_개선사항_목록.md new file mode 100644 index 00000000..666f41f1 --- /dev/null +++ b/화면관리_및_테이블관리_개선사항_목록.md @@ -0,0 +1,386 @@ +# 화면관리 및 테이블관리 시스템 개선사항 목록 + +## 문서 정보 +- **작성일**: 2025-11-03 +- **목적**: 사용자 피드백 기반 개선사항 정리 +- **우선순위**: 높음 + +--- + +## 1. 화면관리 (Screen Management) 개선사항 + +### 1.1 리스트 컬럼 Width 조절 기능 +**현재 문제**: 리스트 컬럼의 너비가 고정되어 있어 사용자가 조절할 수 없음 + +**요구사항**: +- 사용자가 각 컬럼의 너비를 드래그로 조절할 수 있어야 함 +- 조절된 너비는 저장되어 다음 접속 시에도 유지되어야 함 +- 최소/최대 너비 제한 필요 + +**구현 방안**: +- 컬럼 헤더에 리사이저 핸들 추가 +- `ComponentData` 인터페이스에 `columnWidths` 속성 추가 +- PropertiesPanel에서 개별 컬럼 너비 설정 UI 제공 + +**관련 파일**: +- `frontend/components/screen/ScreenDesigner.tsx` +- `frontend/components/screen/RealtimePreview.tsx` +- `frontend/types/screen.ts` + +--- + +### 1.2 되돌리기(Undo) 단축키 에러 수정 +**현재 문제**: 되돌리기 단축키(Ctrl+Z/Cmd+Z) 실행 시 에러 발생 + +**요구사항**: +- 되돌리기 기능이 안정적으로 작동해야 함 +- 다시 실행(Redo) 기능도 함께 제공 (Ctrl+Y/Cmd+Shift+Z) + +**구현 방안**: +- 히스토리 스택 구현 (최대 50개 상태 저장) +- `useUndo` 커스텀 훅 생성 +- 키보드 단축키 이벤트 리스너 추가 + +**관련 파일**: +- `frontend/hooks/useUndo.ts` (신규 생성) +- `frontend/components/screen/ScreenDesigner.tsx` + +--- + +### 1.3 리스트 헤더 스타일 개선 +**현재 문제**: 리스트 헤더가 눈에 잘 띄지 않음 + +**요구사항**: +- 헤더가 시각적으로 구분되어야 함 +- 배경색, 폰트 굵기, 테두리 등으로 강조 + +**구현 방안**: +- 헤더 기본 스타일 변경: + - 배경색: `bg-muted` → `bg-primary/10` + - 폰트: `font-medium` → `font-semibold` + - 하단 테두리: `border-b-2 border-primary` + +**관련 파일**: +- `frontend/components/screen/RealtimePreview.tsx` +- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx` + +--- + +### 1.4 텍스트 줄바꿈 문제 방지 +**현재 문제**: 화면을 줄였을 때 텍스트가 2줄로 나뉘거나 깨지는 현상 + +**요구사항**: +- 텍스트가 항상 1줄로 표시되어야 함 +- 긴 텍스트는 말줄임표(...) 처리 + +**구현 방안**: +- 모든 텍스트 요소에 다음 클래스 적용: + ```tsx + className="truncate whitespace-nowrap overflow-hidden" + ``` +- 툴팁으로 전체 텍스트 표시 + +**관련 파일**: +- 모든 컴포넌트의 텍스트 렌더링 부분 + +--- + +### 1.5 수정 모달 자동 닫기 +**현재 문제**: 수정 완료 후 모달이 자동으로 닫히지 않음 + +**요구사항**: +- 수정 완료 시 모달이 즉시 닫혀야 함 +- 성공 메시지 표시 후 닫기 + +**구현 방안**: +```typescript +const handleUpdate = async () => { + const result = await updateData(formData); + if (result.success) { + toast.success("수정이 완료되었습니다"); + setIsModalOpen(false); // 모달 닫기 + refreshList(); // 목록 새로고침 + } +}; +``` + +**관련 파일**: +- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx` + +--- + +### 1.6 테이블 Align 조절 기능 +**현재 문제**: 테이블 컬럼의 정렬(align)을 사용자가 조절할 수 없음 + +**요구사항**: +- 각 컬럼의 정렬을 left/center/right로 설정 가능해야 함 +- 숫자 타입은 기본적으로 right 정렬 + +**구현 방안**: +- `TableColumnConfig` 인터페이스에 `align` 속성 추가 +- PropertiesPanel에서 정렬 선택 UI 제공 +- 컬럼 타입별 기본 정렬 설정 + +**관련 파일**: +- `frontend/types/screen.ts` +- `frontend/components/screen/PropertiesPanel.tsx` + +--- + +### 1.7 숫자 천 단위 콤마 표시 +**현재 문제**: 숫자가 콤마 없이 표시됨 + +**요구사항**: +- 모든 숫자는 천 단위마다 콤마(,)를 찍어야 함 +- 예: 1000000 → 1,000,000 + +**구현 방안**: +```typescript +// 유틸리티 함수 생성 +export const formatNumber = (value: number | string): string => { + const num = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(num)) return "0"; + return new Intl.NumberFormat("ko-KR").format(num); +}; +``` + +**관련 파일**: +- `frontend/lib/utils/numberFormat.ts` (신규 생성) +- 모든 숫자 표시 컴포넌트 + +--- + +### 1.8 Drilldown UI 개선 +**현재 문제**: 화면이 횡으로 너무 길게 나열됨 + +**요구사항**: +- 계층적 구조로 정보 표시 +- 펼치기/접기 기능으로 공간 절약 + +**구현 방안**: +- Accordion 컴포넌트 활용 +- 탭 네비게이션 구조 적용 +- 마스터-디테일 레이아웃 패턴 + +**관련 파일**: +- `frontend/components/screen/ScreenDesigner.tsx` +- `frontend/components/ui/accordion.tsx` + +--- + +## 2. 테이블 관리 (Table Management) 개선사항 + +### 2.1 테이블 기본 정보 선택 기능 +**현재 문제**: 테이블 기본 정보를 사용자가 선택할 수 없음 + +**요구사항**: +- 테이블 생성/수정 시 다음 정보를 선택 가능해야 함: + - 테이블 타입 (마스터/트랜잭션/코드) + - 카테고리 + - 로그 사용 여부 + - 버전 관리 여부 + - 소프트 삭제 여부 + +**구현 방안**: +- `TableManagement.tsx`에 선택 UI 추가 +- `CREATE TABLE` DDL 자동 생성 시 옵션 반영 + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` + +--- + +### 2.2 컬럼 추가 기능 +**현재 문제**: 기존 테이블에 새 컬럼을 추가하는 기능 부족 + +**요구사항**: +- 테이블 수정 시 컬럼을 동적으로 추가할 수 있어야 함 +- `ALTER TABLE ADD COLUMN` DDL 자동 생성 +- 컬럼 순서 조정 기능 + +**구현 방안**: +```typescript +// 컬럼 추가 API +POST /api/table-management/tables/:tableName/columns +{ + "columnName": "new_column", + "dataType": "VARCHAR(100)", + "nullable": true, + "defaultValue": null +} +``` + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` +- `backend-node/src/services/ddlExecutionService.ts` + +--- + +### 2.3 테이블 복제 기능 +**현재 문제**: 기존 테이블의 구조를 재사용하기 어려움 + +**요구사항**: +- 기존 테이블을 복제하여 새 테이블 생성 +- 다음 정보를 복사: + - 컬럼 구조 (이름, 타입, 제약조건) + - 인덱스 정의 + - 외래키 관계 (선택적) +- 데이터는 복사하지 않음 (구조만) + +**구현 방안**: +```typescript +// 테이블 복제 API +POST /api/table-management/tables/:sourceTableName/clone +{ + "newTableName": "cloned_table", + "includeIndexes": true, + "includeForeignKeys": false, + "copyData": false +} +``` + +**구현 단계**: +1. 원본 테이블 정보 조회 (INFORMATION_SCHEMA) +2. DDL 스크립트 생성 +3. 새 테이블 생성 +4. 인덱스 및 제약조건 추가 +5. 감사 로그 기록 + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` +- `backend-node/src/services/ddlExecutionService.ts` + +**참고 문서**: +- `/Users/kimjuseok/ERP-node/테이블_복제_기능_구현_계획서.md` + +--- + +### 2.4 채번 Rule 관리 기능 +**현재 문제**: 자동 채번 규칙을 사용자가 관리할 수 없음 + +**요구사항**: +- 채번 규칙 생성/수정/삭제 UI +- 규칙 형식: + - 접두사 (예: "PROD-") + - 날짜 포맷 (예: "YYYYMMDD") + - 일련번호 자릿수 (예: 5자리 → 00001) + - 구분자 (예: "-") +- 예시: `PROD-20251103-00001` + +**구현 방안**: +```typescript +interface NumberingRule { + id: string; + ruleName: string; + prefix: string; + dateFormat?: "YYYY" | "YYYYMM" | "YYYYMMDD" | "YYYYMMDD-HH"; + sequenceDigits: number; + separator: string; + resetPeriod: "none" | "daily" | "monthly" | "yearly"; + currentSequence: number; + tableName: string; + columnName: string; +} +``` + +**관련 파일**: +- `frontend/components/table/NumberingRuleManagement.tsx` (신규 생성) +- `backend-node/src/controllers/numberingRuleController.ts` (신규 생성) +- `backend-node/src/services/numberingRuleService.ts` (신규 생성) + +--- + +## 3. 제어 관리 (Flow Management) 개선사항 + +### 3.1 제목 클릭 시 노드 선택 해제 +**현재 문제**: 제목을 입력할 때 백스페이스를 누르면 노드가 삭제됨 + +**요구사항**: +- 제목(플로우명) 입력란 클릭 시 노드 선택이 해제되어야 함 +- 백스페이스 키가 텍스트 입력으로만 작동해야 함 + +**구현 방안**: +```typescript +const handleTitleClick = (e: React.MouseEvent) => { + e.stopPropagation(); // 이벤트 전파 중지 + setSelectedNodes([]); // 노드 선택 해제 +}; + +const handleTitleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); // 백스페이스 키가 노드 삭제로 전파되지 않도록 +}; + + setFlowName(e.target.value)} +/> +``` + +**관련 파일**: +- `frontend/components/flow/FlowDesigner.tsx` +- `frontend/components/flow/FlowCanvas.tsx` + +--- + +## 4. 우선순위 및 구현 일정 + +### 높음 (즉시 수정 필요) +1. **되돌리기 단축키 에러 수정** - 기능 오류 +2. **수정 모달 자동 닫기** - 사용자 경험 저해 +3. **제어관리 제목 입력 문제** - 데이터 손실 위험 +4. **숫자 천 단위 콤마 표시** - 가독성 문제 + +### 중간 (2주 내 완료) +5. **리스트 컬럼 Width 조절** +6. **리스트 헤더 스타일 개선** +7. **텍스트 줄바꿈 문제 방지** +8. **테이블 Align 조절** +9. **컬럼 추가 기능** + +### 낮음 (기능 추가) +10. **테이블 기본 정보 선택** +11. **테이블 복제 기능** +12. **Drilldown UI 개선** +13. **채번 Rule 관리** + +--- + +## 5. 테스트 계획 + +각 개선사항 완료 시 다음을 확인: + +### 기능 테스트 +- [ ] 새 기능이 정상 작동함 +- [ ] 기존 기능에 영향 없음 +- [ ] 에러 처리가 적절함 + +### 사용자 경험 테스트 +- [ ] UI가 직관적임 +- [ ] 반응 속도가 빠름 +- [ ] 모바일/태블릿 대응 + +### 성능 테스트 +- [ ] 대량 데이터 처리 시 성능 저하 없음 +- [ ] 메모리 누수 없음 + +--- + +## 6. 참고 문서 + +- [화면관리 시스템 현황](화면관리_및_테이블관리_개선사항_목록.md) +- [테이블 복제 기능 계획서](테이블_복제_기능_구현_계획서.md) +- [Shadcn/ui 레이아웃 패턴](docs/shadcn-ui-레이아웃-패턴-분석-보고서.md) + +--- + +## 변경 이력 + +| 날짜 | 작성자 | 내용 | +|------|--------|------| +| 2025-11-03 | 개발팀 | 초안 작성 | + From 2ddda380f21d6b16ee2a838740076f5bd9a3a9be Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 09:58:44 +0900 Subject: [PATCH 12/76] =?UTF-8?q?fix:=20=EC=A0=9C=EC=96=B4=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=9C=EB=AA=A9=20=EC=9E=85=EB=A0=A5=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=B1=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=EA=B0=80=20=EC=82=AD=EC=A0=9C=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowToolbar의 플로우 이름 입력 필드에 onKeyDown 이벤트 핸들러 추가 - e.stopPropagation()으로 키 이벤트가 FlowEditor로 전파되지 않도록 차단 - FlowEditor의 Backspace/Delete 키 처리가 입력 필드에 영향을 주지 않도록 수정 --- frontend/components/dataflow/node-editor/FlowToolbar.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f266218a..d837d355 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -116,6 +116,11 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) { setFlowName(e.target.value)} + onKeyDown={(e) => { + // 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지 + // FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음 + e.stopPropagation(); + }} className="h-8 w-[200px] text-sm" placeholder="플로우 이름" /> From c9eacb8f4a025e12aa494c8efa678ed330783f88 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:00:16 +0900 Subject: [PATCH 13/76] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=EC=97=90=20=EC=B2=9C=20=EB=8B=A8=EC=9C=84=20=EC=BD=A4?= =?UTF-8?q?=EB=A7=88=20=EC=9E=90=EB=8F=99=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InteractiveDataTable: number/decimal 타입 셀에 천 단위 콤마 적용 - FlowWidget: 스텝 카운트, 데이터 셀, 페이지 정보에 천 단위 콤마 적용 - formatValue 함수로 숫자 자동 감지 및 포맷팅 - 문자열로 저장된 숫자도 자동으로 포맷팅 처리 - toLocaleString('ko-KR') 사용으로 한국식 숫자 표기 --- .../screen/InteractiveDataTable.tsx | 7 +++- .../components/screen/widgets/FlowWidget.tsx | 40 ++++++++++++------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 17662cac..1a6b4991 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1775,8 +1775,11 @@ export const InteractiveDataTable: React.FC = ({ case "number": case "decimal": - if (typeof value === "number") { - return value.toLocaleString(); + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } } break; diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index bffae228..9cb0a207 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -58,6 +58,28 @@ export function FlowWidget({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { user } = useAuth(); // 사용자 정보 가져오기 + // 숫자 포맷팅 함수 + const formatValue = (value: any): string => { + if (value === null || value === undefined || value === "") { + return "-"; + } + + // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 + if (typeof value === "number") { + return value.toLocaleString("ko-KR"); + } + + if (typeof value === "string") { + const numValue = parseFloat(value); + // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 + if (!isNaN(numValue) && numValue.toString() === value.trim()) { + return numValue.toLocaleString("ko-KR"); + } + } + + return String(value); + }; + // 🆕 전역 상태 관리 const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const resetFlow = useFlowStepStore((state) => state.resetFlow); @@ -698,7 +720,7 @@ export function FlowWidget({ }`} > - {stepCounts[step.id] || 0} + {(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
@@ -884,13 +906,7 @@ export function FlowWidget({ {stepDataColumns.map((col) => (
{columnLabels[col] || col}: - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - + {formatValue(row[col])}
))}
@@ -941,11 +957,7 @@ export function FlowWidget({ )} {stepDataColumns.map((col) => ( - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} + {formatValue(row[col])} ))} @@ -964,7 +976,7 @@ export function FlowWidget({ {/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
- 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건) + 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
표시 개수: From c9905a6dea1d53a77c3f8f986698f9bc99fe05ca Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:07:32 +0900 Subject: [PATCH 14/76] =?UTF-8?q?debug:=20=EC=88=AB=EC=9E=90=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어떤 컬럼이 숫자 포맷팅을 시도하는지 확인 - widgetType과 실제 값의 타입을 콘솔에 출력 - 콤마가 안 찍히는 컬럼 원인 파악용 --- frontend/components/screen/InteractiveDataTable.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 1a6b4991..d5c96785 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1775,6 +1775,12 @@ export const InteractiveDataTable: React.FC = ({ case "number": case "decimal": + console.log(`🔢 숫자 컬럼 포맷팅:`, { + columnName: column.columnName, + widgetType: column.widgetType, + value, + valueType: typeof value, + }); if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { From 7b676a6affff4f84282aefbeee5887e43551a725 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:09:33 +0900 Subject: [PATCH 15/76] =?UTF-8?q?fix:=20TableListComponent=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=88=AB=EC=9E=90=20=ED=83=80=EC=9E=85=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B2=9C=20=EB=8B=A8=EC=9C=84=20=EC=BD=A4=EB=A7=88?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inputType이 number 또는 decimal인 컬럼에 천 단위 콤마 자동 적용 - 문자열로 저장된 숫자도 parseFloat 후 포맷팅 처리 - format 속성보다 inputType을 우선 체크하도록 수정 --- .../components/table-list/TableListComponent.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9ee27c36..2b0cf501 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -647,6 +647,17 @@ export const TableListComponent: React.FC = ({ if (convertedValue !== value) return convertedValue; } + // inputType 기반 포맷팅 (우선순위) + if (column.inputType === "number" || column.inputType === "decimal") { + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } + } + return String(value); + } + switch (column.format) { case "date": if (value) { From 68aafb3732eedeeafec8bf98f6ec18cb9b68e625 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:10:22 +0900 Subject: [PATCH 16/76] =?UTF-8?q?debug:=20TableListComponent=20formatCellV?= =?UTF-8?q?alue=20=EB=94=94=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 컬럼의 inputType, format, value 확인용 - 숫자 포맷팅이 안 되는 원인 파악 --- .../components/table-list/TableListComponent.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 2b0cf501..02705896 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -619,6 +619,17 @@ export const TableListComponent: React.FC = ({ const formatCellValue = useCallback( (value: any, column: ColumnConfig, rowData?: Record) => { + // 디버그: 첫 번째 데이터 행에서만 로그 출력 + if (rowData && Object.keys(rowData)[0]) { + console.log(`🔍 formatCellValue 호출:`, { + columnName: column.columnName, + inputType: column.inputType, + format: column.format, + value, + valueType: typeof value, + }); + } + if (value === null || value === undefined) return "-"; // 🎯 엔티티 컬럼 표시 설정이 있는 경우 From 1c571ee3c3cbc42d120dcd5f21b6fe0ce0e78f76 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:14:32 +0900 Subject: [PATCH 17/76] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9D=98=20=EC=9E=85=EB=A0=A5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=B0=98=20=EC=9E=90=EB=8F=99=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableListComponent: table_type_columns의 input_type 정보를 가져와서 숫자 포맷팅 - getColumnInputTypes API 추가로 컬럼별 입력 타입 조회 - columnMeta에 inputType 포함하여 formatCellValue에서 사용 - 테이블 관리에서 설정한 입력 타입(number/decimal)에 따라 자동으로 천 단위 콤마 표시 - 근본적인 해결: 컬럼명 기반이 아닌 실제 설정값 기반 포맷팅 --- .../screen/InteractiveDataTable.tsx | 6 ---- frontend/lib/api/screen.ts | 6 ++++ .../table-list/TableListComponent.tsx | 35 +++++++++++-------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index d5c96785..1a6b4991 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1775,12 +1775,6 @@ export const InteractiveDataTable: React.FC = ({ case "number": case "decimal": - console.log(`🔢 숫자 컬럼 포맷팅:`, { - columnName: column.columnName, - widgetType: column.widgetType, - value, - valueType: typeof value, - }); if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 695e5a51..3c885a8b 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -242,6 +242,12 @@ export const tableTypeApi = { return data.columns || data || []; }, + // 컬럼 입력 타입 정보 조회 + getColumnInputTypes: async (tableName: string): Promise => { + const response = await apiClient.get(`/table-management/tables/${tableName}/web-types`); + return response.data.data || []; + }, + // 컬럼 웹 타입 설정 setColumnWebType: async ( tableName: string, diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 02705896..35920020 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -284,20 +284,29 @@ export const TableListComponent: React.FC = ({ } const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); + + // 컬럼 입력 타입 정보 가져오기 + const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); + const inputTypeMap: Record = {}; + inputTypes.forEach((col: any) => { + inputTypeMap[col.columnName] = col.inputType; + }); tableColumnCache.set(cacheKey, { columns, + inputTypes, timestamp: Date.now(), }); const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, + inputType: inputTypeMap[col.columnName], }; }); @@ -619,17 +628,6 @@ export const TableListComponent: React.FC = ({ const formatCellValue = useCallback( (value: any, column: ColumnConfig, rowData?: Record) => { - // 디버그: 첫 번째 데이터 행에서만 로그 출력 - if (rowData && Object.keys(rowData)[0]) { - console.log(`🔍 formatCellValue 호출:`, { - columnName: column.columnName, - inputType: column.inputType, - format: column.format, - value, - valueType: typeof value, - }); - } - if (value === null || value === undefined) return "-"; // 🎯 엔티티 컬럼 표시 설정이 있는 경우 @@ -658,8 +656,9 @@ export const TableListComponent: React.FC = ({ if (convertedValue !== value) return convertedValue; } - // inputType 기반 포맷팅 (우선순위) - if (column.inputType === "number" || column.inputType === "decimal") { + // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) + const inputType = meta?.inputType || column.inputType; + if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { @@ -670,6 +669,14 @@ export const TableListComponent: React.FC = ({ } switch (column.format) { + case "number": + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } + } + return String(value); case "date": if (value) { try { From 8a77e6d33cec06d22752eb858e48a1d7ea83f12b Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:32:08 +0900 Subject: [PATCH 18/76] =?UTF-8?q?fix:=20getColumnInputTypes=EA=B0=80=20col?= =?UTF-8?q?umn=5Flabels=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: table_type_columns 테이블 조회 (잘못된 테이블) - 수정: column_labels 테이블 조회 (올바른 테이블) - 이제 테이블 관리에서 설정한 input_type이 정확하게 반영됨 --- .../src/services/tableManagementService.ts | 20 +++++++++---------- .../table-list/TableListComponent.tsx | 9 +++++++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 10de1e73..608f8b96 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2980,20 +2980,20 @@ export class TableManagementService { try { logger.info(`컬럼 입력타입 정보 조회: ${tableName}`); - // table_type_columns에서 입력타입 정보 조회 + // column_labels에서 입력타입 정보 조회 const rawInputTypes = await query( `SELECT - ttc.column_name as "columnName", - ttc.column_name as "displayName", - COALESCE(ttc.input_type, 'text') as "inputType", - COALESCE(ttc.detail_settings, '{}') as "detailSettings", - ttc.is_nullable as "isNullable", + cl.column_name as "columnName", + cl.column_label as "displayName", + COALESCE(cl.input_type, 'text') as "inputType", + '{}'::jsonb as "detailSettings", + ic.is_nullable as "isNullable", ic.data_type as "dataType" - FROM table_type_columns ttc + FROM column_labels cl LEFT JOIN information_schema.columns ic - ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name - WHERE ttc.table_name = $1 - ORDER BY ttc.display_order, ttc.column_name`, + ON cl.table_name = ic.table_name AND cl.column_name = ic.column_name + WHERE cl.table_name = $1 + ORDER BY cl.column_name`, [tableName] ); diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 35920020..435d0bd3 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -287,9 +287,11 @@ export const TableListComponent: React.FC = ({ // 컬럼 입력 타입 정보 가져오기 const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); + console.log("📋 테이블의 inputType 정보:", tableConfig.selectedTable, inputTypes); const inputTypeMap: Record = {}; inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; + console.log(` - ${col.columnName}: ${col.inputType}`); }); tableColumnCache.set(cacheKey, { @@ -659,6 +661,7 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; if (inputType === "number" || inputType === "decimal") { + console.log(`✅ 숫자 포맷팅 적용: ${column.columnName} = ${value} (inputType: ${inputType})`); if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { @@ -666,6 +669,12 @@ export const TableListComponent: React.FC = ({ } } return String(value); + } else if (value !== null && value !== undefined && value !== "") { + // 숫자처럼 보이지만 inputType이 설정 안된 경우 + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue) && typeof value === "string" && /^\d+$/.test(value)) { + console.log(`⚠️ ${column.columnName}은 숫자 값이지만 inputType이 '${inputType}'로 설정되어 있어 포맷팅 안 됨`); + } } switch (column.format) { From dcf07fdd5e4ade9169dbb8a632715e2b2bb45018 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:45:16 +0900 Subject: [PATCH 19/76] =?UTF-8?q?chore:=20TableListComponent=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 숫자 포맷팅 확인 완료 - column_labels 테이블 조회로 정확한 input_type 가져오기 검증 완료 --- .../components/table-list/TableListComponent.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 435d0bd3..35920020 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -287,11 +287,9 @@ export const TableListComponent: React.FC = ({ // 컬럼 입력 타입 정보 가져오기 const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); - console.log("📋 테이블의 inputType 정보:", tableConfig.selectedTable, inputTypes); const inputTypeMap: Record = {}; inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; - console.log(` - ${col.columnName}: ${col.inputType}`); }); tableColumnCache.set(cacheKey, { @@ -661,7 +659,6 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; if (inputType === "number" || inputType === "decimal") { - console.log(`✅ 숫자 포맷팅 적용: ${column.columnName} = ${value} (inputType: ${inputType})`); if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { @@ -669,12 +666,6 @@ export const TableListComponent: React.FC = ({ } } return String(value); - } else if (value !== null && value !== undefined && value !== "") { - // 숫자처럼 보이지만 inputType이 설정 안된 경우 - const numValue = typeof value === "string" ? parseFloat(value) : value; - if (!isNaN(numValue) && typeof value === "string" && /^\d+$/.test(value)) { - console.log(`⚠️ ${column.columnName}은 숫자 값이지만 inputType이 '${inputType}'로 설정되어 있어 포맷팅 안 됨`); - } } switch (column.format) { From b40e3d4b8b85c95427e9d3272ba09ab1ff5a162b Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:46:17 +0900 Subject: [PATCH 20/76] =?UTF-8?q?feat:=20InteractiveDataTable=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=84=88=EB=B9=84=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 컬럼 헤더 오른쪽에 리사이즈 핸들 추가 - 드래그로 컬럼 너비를 자유롭게 조절 가능 - 최소 너비 80px 보장 - 마지막 컬럼 제외하고 모든 컬럼에 리사이즈 핸들 표시 - hover 시 파란색으로 강조되어 UX 개선 --- .../screen/InteractiveDataTable.tsx | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 1a6b4991..354b5a90 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -106,6 +106,7 @@ export const InteractiveDataTable: React.FC = ({ const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); const [selectedRows, setSelectedRows] = useState>(new Set()); + const [columnWidths, setColumnWidths] = useState>({}); // SaveModal 상태 (등록/수정 통합) const [showSaveModal, setShowSaveModal] = useState(false); @@ -1932,15 +1933,48 @@ export const InteractiveDataTable: React.FC = ({ /> )} - {visibleColumns.map((column: DataTableColumn) => ( - - {column.label} - - ))} + {visibleColumns.map((column: DataTableColumn, columnIndex) => { + const columnWidth = columnWidths[column.id]; + const defaultWidth = `${((column.gridColumns || 2) / totalGridColumns) * 100}%`; + + return ( + + {column.label} + {/* 리사이즈 핸들 */} + {columnIndex < visibleColumns.length - 1 && ( +
{ + e.preventDefault(); + const startX = e.clientX; + const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); + + const handleMouseMove = (moveEvent: MouseEvent) => { + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + setColumnWidths(prev => ({ ...prev, [column.id]: newWidth })); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + )} + + ); + })} {/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */} From a3a4664bb0f07e0c4074a8e20de22755c26f8f0c Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:49:09 +0900 Subject: [PATCH 21/76] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=A4=91=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A0=ED=83=9D=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컬럼 헤더에 select-none, userSelect: 'none' 추가 - 드래그 중 document.body.userSelect = 'none'으로 전역 텍스트 선택 차단 - 드래그 완료 후 userSelect 복원 - 드래그 중 cursor를 col-resize로 고정하여 UX 개선 --- .../components/screen/InteractiveDataTable.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 354b5a90..3b6299f9 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1940,10 +1940,11 @@ export const InteractiveDataTable: React.FC = ({ return ( {column.label} @@ -1956,13 +1957,22 @@ export const InteractiveDataTable: React.FC = ({ const startX = e.clientX; const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); + // 드래그 중 텍스트 선택 방지 + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); const diff = moveEvent.clientX - startX; const newWidth = Math.max(80, startWidth + diff); setColumnWidths(prev => ({ ...prev, [column.id]: newWidth })); }; const handleMouseUp = () => { + // 텍스트 선택 복원 + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; From 56cd2a940792178d7025c9109baa03606733e3aa Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:51:10 +0900 Subject: [PATCH 22/76] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=8B=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=A0=84=ED=8C=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e.stopPropagation() 추가로 리사이즈 핸들 드래그 시 상위 컴포넌트로 이벤트 전파 차단 - 화면 디자이너에서 컴포넌트가 의도치 않게 이동되는 문제 해결 --- frontend/components/screen/InteractiveDataTable.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 3b6299f9..1eab54ed 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1954,6 +1954,8 @@ export const InteractiveDataTable: React.FC = ({ className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-500" onMouseDown={(e) => { e.preventDefault(); + e.stopPropagation(); // 상위 컴포넌트 드래그 이벤트 방지 + const startX = e.clientX; const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); From 107f722e7ac79252a8e26c2d62865c689d54d550 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 10:52:28 +0900 Subject: [PATCH 23/76] =?UTF-8?q?fix:=20=EC=8B=A4=EC=A0=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RealtimePreviewDynamic의 draggable을 isDesignMode 조건부로 변경 - 디자인 모드(화면 편집)에서만 드래그 가능 - 실제 화면(프리뷰/실행)에서는 드래그 불가능 - onDragStart, onDragEnd도 조건부로 적용 --- frontend/components/screen/RealtimePreviewDynamic.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index f4e0fec9..72739e71 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -275,9 +275,9 @@ export const RealtimePreviewDynamic: React.FC = ({ style={{ ...baseStyle, ...selectionStyle }} onClick={handleClick} onDoubleClick={handleDoubleClick} - draggable - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} + draggable={isDesignMode} // 디자인 모드에서만 드래그 가능 + onDragStart={isDesignMode ? handleDragStart : undefined} + onDragEnd={isDesignMode ? handleDragEnd : undefined} > {/* 동적 컴포넌트 렌더링 */}
Date: Mon, 3 Nov 2025 10:54:23 +0900 Subject: [PATCH 24/76] =?UTF-8?q?feat:=20TableListComponent=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=84=88=EB=B9=84=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 화면에서 사용되는 TableListComponent에도 리사이즈 기능 추가 - InteractiveDataTable과 동일한 리사이즈 핸들 구현 - columnWidths 상태로 각 컬럼 너비 관리 - 드래그 중 텍스트 선택 방지 및 이벤트 전파 차단 - 최소 너비 80px 보장 --- .../table-list/TableListComponent.tsx | 95 ++++++++++++++----- 1 file changed, 70 insertions(+), 25 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 35920020..c957e3cc 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -244,6 +244,7 @@ export const TableListComponent: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [isDragging, setIsDragging] = useState(false); const [draggedRowIndex, setDraggedRowIndex] = useState(null); + const [columnWidths, setColumnWidths] = useState>({}); const [isAllSelected, setIsAllSelected] = useState(false); // 필터 설정 관련 상태 @@ -1018,31 +1019,75 @@ export const TableListComponent: React.FC = ({ className="sticky top-0 z-10 bg-background" > - {visibleColumns.map((column) => ( - column.sortable && handleSort(column.columnName)} - > - {column.columnName === "__checkbox__" ? ( - renderCheckboxHeader() - ) : ( -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} - )} -
- )} - - ))} + {visibleColumns.map((column, columnIndex) => { + const columnWidth = columnWidths[column.columnName]; + const defaultWidth = `${100 / visibleColumns.length}%`; + + return ( + column.sortable && handleSort(column.columnName)} + > + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && sortColumn === column.columnName && ( + {sortDirection === "asc" ? "↑" : "↓"} + )} +
+ )} + {/* 리사이즈 핸들 */} + {columnIndex < visibleColumns.length - 1 && ( +
e.stopPropagation()} // 정렬 클릭 방지 + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX; + const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); + + // 드래그 중 텍스트 선택 방지 + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + setColumnWidths(prev => ({ ...prev, [column.columnName]: newWidth })); + }; + + const handleMouseUp = () => { + // 텍스트 선택 복원 + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + )} + + ); + })} From 4a5c21a3ba45aeee703467c4e9a7f15f8fe6b50c Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 11:51:48 +0900 Subject: [PATCH 25/76] =?UTF-8?q?fix:=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=ED=95=B8=EB=93=A4=20=ED=81=B4=EB=A6=AD=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 핸들 너비를 1px에서 2px로 증가 - z-index: 20 추가로 다른 요소 위에 표시 - padding과 negative margin으로 클릭 영역 확대 (좌우 4px씩) - onClick에 stopPropagation 추가하여 정렬 클릭 방지 - 더 쉽게 클릭하고 드래그할 수 있도록 개선 --- frontend/components/screen/InteractiveDataTable.tsx | 4 +++- .../lib/registry/components/table-list/TableListComponent.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 1eab54ed..8a31756c 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1951,7 +1951,9 @@ export const InteractiveDataTable: React.FC = ({ {/* 리사이즈 핸들 */} {columnIndex < visibleColumns.length - 1 && (
e.stopPropagation()} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); // 상위 컴포넌트 드래그 이벤트 방지 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index c957e3cc..5dd90227 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1051,7 +1051,8 @@ export const TableListComponent: React.FC = ({ {/* 리사이즈 핸들 */} {columnIndex < visibleColumns.length - 1 && (
e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { e.preventDefault(); From 9f501aa83919a1f3fa09ad2ec67fa40c6860a3f9 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 11:54:34 +0900 Subject: [PATCH 26/76] =?UTF-8?q?debug:=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=ED=95=B8=EB=93=A4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마우스다운, 드래그, 마우스업 이벤트 로그 - 시작 너비, 이동 거리, 새 너비 출력 - 이벤트가 제대로 발생하는지 확인용 --- .../lib/registry/components/table-list/TableListComponent.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 5dd90227..a341d086 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1055,11 +1055,13 @@ export const TableListComponent: React.FC = ({ style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }} onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { + console.log('🖱️ 리사이즈 핸들 마우스다운', column.columnName); e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); + console.log('시작 너비:', startWidth); // 드래그 중 텍스트 선택 방지 document.body.style.userSelect = 'none'; @@ -1069,10 +1071,12 @@ export const TableListComponent: React.FC = ({ moveEvent.preventDefault(); const diff = moveEvent.clientX - startX; const newWidth = Math.max(80, startWidth + diff); + console.log('드래그 중:', { diff, newWidth }); setColumnWidths(prev => ({ ...prev, [column.columnName]: newWidth })); }; const handleMouseUp = () => { + console.log('마우스 업!'); // 텍스트 선택 복원 document.body.style.userSelect = ''; document.body.style.cursor = ''; From 7edd0cc1b0547b71a11f5dd7f193ddf19b1ee507 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 11:55:40 +0900 Subject: [PATCH 27/76] =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/CustomMetricTestWidget.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 8483c82f..f7d97779 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -272,26 +272,22 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg const decimals = config?.decimals ?? 0; const formattedValue = value.toFixed(decimals); - // 통계 카드 렌더링 + // 통계 카드 렌더링 (전체 크기 꽉 차게) return ( -
-
- {/* 제목 */} -
{config?.title || "통계"}
+
+ {/* 제목 */} +
{config?.title || "통계"}
- {/* 값 */} -
- {formattedValue} - {config?.unit && {config.unit}} -
- - {/* 필터 표시 (디버깅용, 작게) */} - {config?.filters && config.filters.length > 0 && ( -
- 필터: {config.filters.length}개 적용됨 -
- )} + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}}
+ + {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
필터: {config.filters.length}개 적용됨
+ )}
); } From 48cacf0409d79b0cf09c653cfb088de9155db5c5 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 11:55:45 +0900 Subject: [PATCH 28/76] =?UTF-8?q?perf:=20requestAnimationFrame=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 드래그 중 과도한 리렌더링 방지 - requestAnimationFrame으로 throttling 적용 - 초당 60프레임으로 제한하여 부드러운 리사이즈 - cancelAnimationFrame으로 중복 업데이트 방지 --- .../table-list/TableListComponent.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index a341d086..18d3340c 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1067,16 +1067,30 @@ export const TableListComponent: React.FC = ({ document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; + let rafId: number | null = null; + const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); - const diff = moveEvent.clientX - startX; - const newWidth = Math.max(80, startWidth + diff); - console.log('드래그 중:', { diff, newWidth }); - setColumnWidths(prev => ({ ...prev, [column.columnName]: newWidth })); + + if (rafId) { + cancelAnimationFrame(rafId); + } + + rafId = requestAnimationFrame(() => { + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + console.log('드래그 중:', { diff, newWidth }); + setColumnWidths(prev => ({ ...prev, [column.columnName]: newWidth })); + }); }; const handleMouseUp = () => { console.log('마우스 업!'); + + if (rafId) { + cancelAnimationFrame(rafId); + } + // 텍스트 선택 복원 document.body.style.userSelect = ''; document.body.style.cursor = ''; From 97ce6d36913e9358ee5e505bf1b080f2fa89c18d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 11:57:01 +0900 Subject: [PATCH 29/76] =?UTF-8?q?fix:=20useRef=EB=A1=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B7=BC=EB=B3=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - columnRefs로 DOM 요소 직접 참조 - 드래그 중에는 DOM 스타일만 변경 (리렌더링 없음) - 드래그 완료 시에만 state 업데이트 - 불필요한 컴포넌트 재초기화 완전 제거 - 부드러운 리사이즈 경험 제공 --- .../table-list/TableListComponent.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 18d3340c..6b9f1a8f 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -245,6 +245,7 @@ export const TableListComponent: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [draggedRowIndex, setDraggedRowIndex] = useState(null); const [columnWidths, setColumnWidths] = useState>({}); + const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); // 필터 설정 관련 상태 @@ -1026,6 +1027,7 @@ export const TableListComponent: React.FC = ({ return ( (columnRefs.current[column.columnName] = el)} className={cn( "relative h-10 px-2 py-2 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background select-none sm:h-12 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap", column.sortable && "cursor-pointer" @@ -1059,36 +1061,36 @@ export const TableListComponent: React.FC = ({ e.preventDefault(); e.stopPropagation(); + const thElement = columnRefs.current[column.columnName]; + if (!thElement) return; + const startX = e.clientX; - const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); + const startWidth = columnWidth || thElement.offsetWidth; console.log('시작 너비:', startWidth); // 드래그 중 텍스트 선택 방지 document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; - let rafId: number | null = null; - const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); - if (rafId) { - cancelAnimationFrame(rafId); - } + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); - rafId = requestAnimationFrame(() => { - const diff = moveEvent.clientX - startX; - const newWidth = Math.max(80, startWidth + diff); - console.log('드래그 중:', { diff, newWidth }); - setColumnWidths(prev => ({ ...prev, [column.columnName]: newWidth })); - }); + // 직접 DOM 스타일 변경 (리렌더링 없음) + if (thElement) { + thElement.style.width = `${newWidth}px`; + } }; const handleMouseUp = () => { console.log('마우스 업!'); - if (rafId) { - cancelAnimationFrame(rafId); + // 최종 너비를 state에 저장 + if (thElement) { + const finalWidth = thElement.offsetWidth; + setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth })); } // 텍스트 선택 복원 From c8540b170e128ae151a32424b68ef511ba3fab63 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:01:47 +0900 Subject: [PATCH 30/76] =?UTF-8?q?fix:=20table-layout=20fixed=EB=A1=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tableLayout: 'fixed' 추가로 컬럼 너비가 DOM 스타일로 제어 가능하도록 설정 - table-layout: auto(기본값)에서는 브라우저가 자동으로 너비를 재조정하여 무시됨 - fixed 모드에서는 설정한 너비가 정확하게 적용됨 --- .../components/table-list/TableListComponent.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6b9f1a8f..81597a83 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1013,6 +1013,7 @@ export const TableListComponent: React.FC = ({ style={{ borderCollapse: "collapse", width: "100%", + tableLayout: "fixed", }} > {/* 헤더 (sticky) */} @@ -1057,26 +1058,31 @@ export const TableListComponent: React.FC = ({ style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }} onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { - console.log('🖱️ 리사이즈 핸들 마우스다운', column.columnName); + console.log('🖱️ 마우스 다운!', column.columnName); e.preventDefault(); e.stopPropagation(); const thElement = columnRefs.current[column.columnName]; - if (!thElement) return; + if (!thElement) { + console.error('❌ thElement를 찾을 수 없음!'); + return; + } const startX = e.clientX; const startWidth = columnWidth || thElement.offsetWidth; - console.log('시작 너비:', startWidth); + console.log('✅ 시작:', { startX, startWidth }); // 드래그 중 텍스트 선택 방지 document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; const handleMouseMove = (moveEvent: MouseEvent) => { + console.log('🐭 마우스 무브!', moveEvent.clientX); moveEvent.preventDefault(); const diff = moveEvent.clientX - startX; const newWidth = Math.max(80, startWidth + diff); + console.log('📏 새 너비:', newWidth); // 직접 DOM 스타일 변경 (리렌더링 없음) if (thElement) { @@ -1085,8 +1091,7 @@ export const TableListComponent: React.FC = ({ }; const handleMouseUp = () => { - console.log('마우스 업!'); - + console.log('⬆️ 마우스 업!'); // 최종 너비를 state에 저장 if (thElement) { const finalWidth = thElement.offsetWidth; @@ -1101,6 +1106,7 @@ export const TableListComponent: React.FC = ({ document.removeEventListener('mouseup', handleMouseUp); }; + console.log('👂 이벤트 리스너 등록'); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }} From 3ada095e43a2055364825af49c6613ad7c70fb81 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:06:57 +0900 Subject: [PATCH 31/76] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=B5=9C=EC=86=8C=20=EB=84=88?= =?UTF-8?q?=EB=B9=84=2080px=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSS minWidth 제거 (table-layout: fixed와 충돌) - JavaScript에서 Math.max(80, width)로 최소 너비 보장 - 드래그 중과 마우스 업 시 모두 80px 최소값 적용 - 모든 디버그 로그 제거 - 깔끔하고 부드러운 리사이즈 완성 --- .../components/table-list/TableListComponent.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 81597a83..4fa655e2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1036,7 +1036,6 @@ export const TableListComponent: React.FC = ({ style={{ textAlign: column.align || "left", width: columnWidth ? `${columnWidth}px` : defaultWidth, - minWidth: '80px', userSelect: 'none' }} onClick={() => column.sortable && handleSort(column.columnName)} @@ -1058,31 +1057,24 @@ export const TableListComponent: React.FC = ({ style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }} onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { - console.log('🖱️ 마우스 다운!', column.columnName); e.preventDefault(); e.stopPropagation(); const thElement = columnRefs.current[column.columnName]; - if (!thElement) { - console.error('❌ thElement를 찾을 수 없음!'); - return; - } + if (!thElement) return; const startX = e.clientX; const startWidth = columnWidth || thElement.offsetWidth; - console.log('✅ 시작:', { startX, startWidth }); // 드래그 중 텍스트 선택 방지 document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; const handleMouseMove = (moveEvent: MouseEvent) => { - console.log('🐭 마우스 무브!', moveEvent.clientX); moveEvent.preventDefault(); const diff = moveEvent.clientX - startX; const newWidth = Math.max(80, startWidth + diff); - console.log('📏 새 너비:', newWidth); // 직접 DOM 스타일 변경 (리렌더링 없음) if (thElement) { @@ -1091,10 +1083,9 @@ export const TableListComponent: React.FC = ({ }; const handleMouseUp = () => { - console.log('⬆️ 마우스 업!'); // 최종 너비를 state에 저장 if (thElement) { - const finalWidth = thElement.offsetWidth; + const finalWidth = Math.max(80, thElement.offsetWidth); setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth })); } @@ -1106,7 +1097,6 @@ export const TableListComponent: React.FC = ({ document.removeEventListener('mouseup', handleMouseUp); }; - console.log('👂 이벤트 리스너 등록'); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }} From 511884f323e3fc29aec40a603194eb644c5771bc Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:13:56 +0900 Subject: [PATCH 32/76] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EB=84=88=EB=B9=84=20=EC=9D=B4=ED=95=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=84=EC=96=B4=EB=93=A4=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ 해결 방법: - 백분율 defaultWidth 제거, 초기값은 undefined로 설정 - ref callback에서 첫 렌더링 시 실제 offsetWidth 측정 - 측정한 실제 너비를 columnWidths state에 저장 - 이후 드래그로 80px까지 줄일 수 있음 ✅ 적용 파일: - TableListComponent.tsx (실제 화면) - InteractiveDataTable.tsx (디자인 모드) ✅ 기술적 개선: - table-layout: fixed + 동적 초기 너비 측정 - DOM 직접 조작으로 부드러운 리사이즈 - 최소 80px 보장 (Math.max) --- .../screen/InteractiveDataTable.tsx | 38 +++++++++++++++---- .../table-list/TableListComponent.tsx | 17 +++++++-- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 8a31756c..c9b24892 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1921,7 +1921,7 @@ export const InteractiveDataTable: React.FC = ({ {visibleColumns.length > 0 ? ( <>
- +
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */} @@ -1935,15 +1935,25 @@ export const InteractiveDataTable: React.FC = ({ )} {visibleColumns.map((column: DataTableColumn, columnIndex) => { const columnWidth = columnWidths[column.id]; - const defaultWidth = `${((column.gridColumns || 2) / totalGridColumns) * 100}%`; return ( { + // 첫 렌더링 시 실제 너비를 측정해서 상태에 저장 + if (el && !columnWidth) { + const measuredWidth = el.offsetWidth; + if (measuredWidth > 0) { + setColumnWidths(prev => ({ + ...prev, + [column.id]: measuredWidth + })); + } + } + }} className="relative bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700 select-none" style={{ - width: columnWidth ? `${columnWidth}px` : defaultWidth, - minWidth: '80px', + width: columnWidth ? `${columnWidth}px` : undefined, userSelect: 'none' }} > @@ -1956,10 +1966,13 @@ export const InteractiveDataTable: React.FC = ({ onClick={(e) => e.stopPropagation()} onMouseDown={(e) => { e.preventDefault(); - e.stopPropagation(); // 상위 컴포넌트 드래그 이벤트 방지 + e.stopPropagation(); + + const thElement = e.currentTarget.parentElement as HTMLTableCellElement; + if (!thElement) return; const startX = e.clientX; - const startWidth = columnWidth || (e.currentTarget.parentElement?.offsetWidth || 100); + const startWidth = columnWidth || thElement.offsetWidth; // 드래그 중 텍스트 선택 방지 document.body.style.userSelect = 'none'; @@ -1967,12 +1980,23 @@ export const InteractiveDataTable: React.FC = ({ const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); + const diff = moveEvent.clientX - startX; const newWidth = Math.max(80, startWidth + diff); - setColumnWidths(prev => ({ ...prev, [column.id]: newWidth })); + + // 직접 DOM 스타일 변경 (리렌더링 없음) + if (thElement) { + thElement.style.width = `${newWidth}px`; + } }; const handleMouseUp = () => { + // 최종 너비를 state에 저장 + if (thElement) { + const finalWidth = Math.max(80, thElement.offsetWidth); + setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth })); + } + // 텍스트 선택 복원 document.body.style.userSelect = ''; document.body.style.cursor = ''; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 4fa655e2..6a753734 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1023,19 +1023,30 @@ export const TableListComponent: React.FC = ({ {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[column.columnName]; - const defaultWidth = `${100 / visibleColumns.length}%`; return (
(columnRefs.current[column.columnName] = el)} + ref={(el) => { + columnRefs.current[column.columnName] = el; + // 첫 렌더링 시 실제 너비를 측정해서 상태에 저장 + if (el && !columnWidth) { + const measuredWidth = el.offsetWidth; + if (measuredWidth > 0) { + setColumnWidths(prev => ({ + ...prev, + [column.columnName]: measuredWidth + })); + } + } + }} className={cn( "relative h-10 px-2 py-2 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background select-none sm:h-12 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap", column.sortable && "cursor-pointer" )} style={{ textAlign: column.align || "left", - width: columnWidth ? `${columnWidth}px` : defaultWidth, + width: columnWidth ? `${columnWidth}px` : undefined, userSelect: 'none' }} onClick={() => column.sortable && handleSort(column.columnName)} From 3332c87293f6ced6337c8f5d66e06b7510b8ca18 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:18:50 +0900 Subject: [PATCH 33/76] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=AC=B4=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B0=8F=20=EC=9B=90=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=B3=B5=EA=B7=80=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref callback에서 state 업데이트 제거 - useEffect + setTimeout으로 초기 너비 측정 (한 번만) - hasInitializedWidths useRef로 중복 측정 방지 - columnRefs useRef로 DOM 직접 참조 - 드래그 중 리렌더링 없이 DOM만 직접 조작 - 부드럽고 정확한 리사이즈 구현 완료 --- .../screen/InteractiveDataTable.tsx | 46 +++++++++++++------ .../table-list/TableListComponent.tsx | 44 ++++++++++++------ 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index c9b24892..5d31da83 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -107,6 +107,8 @@ export const InteractiveDataTable: React.FC = ({ const [total, setTotal] = useState(0); const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); + const hasInitializedWidthsRef = useRef(false); + const columnRefs = useRef>({}); // SaveModal 상태 (등록/수정 통합) const [showSaveModal, setShowSaveModal] = useState(false); @@ -409,6 +411,35 @@ export const InteractiveDataTable: React.FC = ({ // 페이지 크기 설정 const pageSize = component.pagination?.pageSize || 10; + // 초기 컬럼 너비 측정 (한 번만) + useEffect(() => { + if (!hasInitializedWidthsRef.current && visibleColumns.length > 0) { + // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + const timer = setTimeout(() => { + const newWidths: Record = {}; + let hasAnyWidth = false; + + visibleColumns.forEach((column) => { + const thElement = columnRefs.current[column.id]; + if (thElement) { + const measuredWidth = thElement.offsetWidth; + if (measuredWidth > 0) { + newWidths[column.id] = measuredWidth; + hasAnyWidth = true; + } + } + }); + + if (hasAnyWidth) { + setColumnWidths(newWidths); + hasInitializedWidthsRef.current = true; + } + }, 100); + + return () => clearTimeout(timer); + } + }, [visibleColumns]); + // 데이터 로드 함수 const loadData = useCallback( async (page: number = 1, searchParams: Record = {}) => { @@ -1939,18 +1970,7 @@ export const InteractiveDataTable: React.FC = ({ return ( { - // 첫 렌더링 시 실제 너비를 측정해서 상태에 저장 - if (el && !columnWidth) { - const measuredWidth = el.offsetWidth; - if (measuredWidth > 0) { - setColumnWidths(prev => ({ - ...prev, - [column.id]: measuredWidth - })); - } - } - }} + ref={(el) => (columnRefs.current[column.id] = el)} className="relative bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700 select-none" style={{ width: columnWidth ? `${columnWidth}px` : undefined, @@ -1968,7 +1988,7 @@ export const InteractiveDataTable: React.FC = ({ e.preventDefault(); e.stopPropagation(); - const thElement = e.currentTarget.parentElement as HTMLTableCellElement; + const thElement = columnRefs.current[column.id]; if (!thElement) return; const startX = e.clientX; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6a753734..10e17fa5 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -247,6 +247,7 @@ export const TableListComponent: React.FC = ({ const [columnWidths, setColumnWidths] = useState>({}); const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); + const hasInitializedWidths = useRef(false); // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); @@ -794,6 +795,35 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.refreshInterval, isDesignMode]); + // 초기 컬럼 너비 측정 (한 번만) + useEffect(() => { + if (!hasInitializedWidths.current && visibleColumns.length > 0) { + // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + const timer = setTimeout(() => { + const newWidths: Record = {}; + let hasAnyWidth = false; + + visibleColumns.forEach((column) => { + const thElement = columnRefs.current[column.columnName]; + if (thElement) { + const measuredWidth = thElement.offsetWidth; + if (measuredWidth > 0) { + newWidths[column.columnName] = measuredWidth; + hasAnyWidth = true; + } + } + }); + + if (hasAnyWidth) { + setColumnWidths(newWidths); + hasInitializedWidths.current = true; + } + }, 100); + + return () => clearTimeout(timer); + } + }, [visibleColumns]); + // ======================================== // 페이지네이션 JSX // ======================================== @@ -1027,19 +1057,7 @@ export const TableListComponent: React.FC = ({ return ( { - columnRefs.current[column.columnName] = el; - // 첫 렌더링 시 실제 너비를 측정해서 상태에 저장 - if (el && !columnWidth) { - const measuredWidth = el.offsetWidth; - if (measuredWidth > 0) { - setColumnWidths(prev => ({ - ...prev, - [column.columnName]: measuredWidth - })); - } - } - }} + ref={(el) => (columnRefs.current[column.columnName] = el)} className={cn( "relative h-10 px-2 py-2 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background select-none sm:h-12 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap", column.sortable && "cursor-pointer" From 3a75549ffec658609651c5f8bdd8e242a6304be6 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:22:13 +0900 Subject: [PATCH 34/76] =?UTF-8?q?fix:=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=AC=EB=9F=BC=EC=9D=84=2048px=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EB=84=88=EB=B9=84=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InteractiveDataTable의 체크박스 컬럼/셀을 48px 고정 - width, minWidth, maxWidth 모두 48px로 설정 - 플로우 위젯처럼 작고 깔끔한 체크박스 컬럼 - 리사이즈 대상에서 제외 --- frontend/components/screen/InteractiveDataTable.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 5d31da83..a588b354 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1957,7 +1957,10 @@ export const InteractiveDataTable: React.FC = ({ {/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( - + 0} onCheckedChange={handleSelectAll} @@ -2054,7 +2057,10 @@ export const InteractiveDataTable: React.FC = ({ {/* 체크박스 셀 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( - + handleRowSelect(rowIndex, checked as boolean)} From 6aa25fa852824de2755373bc89c71a8d18b06b10 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:24:28 +0900 Subject: [PATCH 35/76] =?UTF-8?q?fix:=20TableListComponent=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=BB=AC=EB=9F=BC=2048px=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 체크박스 컬럼(__checkbox__)을 48px 고정 너비로 설정 - width, minWidth, maxWidth 모두 48px 적용 - 체크박스 컬럼에서 리사이즈 핸들 제거 - 초기 너비 측정 시 체크박스 컬럼 제외 - 테이블 헤더와 본문 셀 모두 적용 - InteractiveDataTable과 일관된 체크박스 컬럼 스타일 --- .../components/table-list/TableListComponent.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 10e17fa5..dccbce67 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -804,6 +804,9 @@ export const TableListComponent: React.FC = ({ let hasAnyWidth = false; visibleColumns.forEach((column) => { + // 체크박스 컬럼은 제외 (고정 48px) + if (column.columnName === "__checkbox__") return; + const thElement = columnRefs.current[column.columnName]; if (thElement) { const measuredWidth = thElement.offsetWidth; @@ -1064,7 +1067,9 @@ export const TableListComponent: React.FC = ({ )} style={{ textAlign: column.align || "left", - width: columnWidth ? `${columnWidth}px` : undefined, + width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined), + minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, + maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, userSelect: 'none' }} onClick={() => column.sortable && handleSort(column.columnName)} @@ -1079,8 +1084,8 @@ export const TableListComponent: React.FC = ({ )} )} - {/* 리사이즈 핸들 */} - {columnIndex < visibleColumns.length - 1 && ( + {/* 리사이즈 핸들 (체크박스 제외) */} + {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
= ({ )} style={{ textAlign: column.align || "left", - width: `${100 / visibleColumns.length}%`, // 컬럼 수에 따라 균등 분배 + width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`, + minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, + maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, }} > {column.columnName === "__checkbox__" From 5376d7198d5676fbb252f37f29a3d4a5fa3af229 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 12:28:30 +0900 Subject: [PATCH 36/76] =?UTF-8?q?fix:=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=AC=EB=9F=BC=20=ED=8C=A8=EB=94=A9=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=A4=91=EC=95=99=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 체크박스 컬럼에서 px 패딩 제거 (px-0) - 체크박스 컬럼 중앙 정렬 (textAlign: center) - 일반 컬럼은 기존 패딩 유지 - 체크박스와 다른 컬럼의 시각적 구분 명확화 - 불필요한 공간 제거로 깔끔한 UI --- .../components/table-list/TableListComponent.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index dccbce67..780b4c4d 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1062,11 +1062,12 @@ export const TableListComponent: React.FC = ({ key={column.columnName} ref={(el) => (columnRefs.current[column.columnName] = el)} className={cn( - "relative h-10 px-2 py-2 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background select-none sm:h-12 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap", + "relative h-10 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background select-none sm:h-12 sm:text-sm sm:whitespace-nowrap", + column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3", column.sortable && "cursor-pointer" )} style={{ - textAlign: column.align || "left", + textAlign: column.columnName === "__checkbox__" ? "center" : (column.align || "left"), width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined), minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, @@ -1194,10 +1195,11 @@ export const TableListComponent: React.FC = ({
Date: Mon, 3 Nov 2025 13:25:57 +0900 Subject: [PATCH 37/76] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EA=B0=9C=EC=84=A0=20-=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EA=B0=80=EC=9A=B4=EB=8D=B0,=20=EC=88=AB=EC=9E=90?= =?UTF-8?q?=20=EC=9A=B0=EC=B8=A1=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 테이블 헤더를 가운데 정렬 (text-center) - 숫자 타입(number, decimal) 데이터를 우측 정렬 - TableListComponent: inputType 기반 숫자 판단 - InteractiveDataTable: widgetType 기반 숫자 판단 - 체크박스는 기존대로 가운데 정렬 유지 - 일반 텍스트는 좌측 정렬 유지 더 읽기 쉬운 테이블 레이아웃 완성 --- .../screen/InteractiveDataTable.tsx | 19 +++++++++++++------ .../table-list/TableListComponent.tsx | 8 ++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index a588b354..45a5e488 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1974,7 +1974,7 @@ export const InteractiveDataTable: React.FC = ({ (columnRefs.current[column.id] = el)} - className="relative bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700 select-none" + className="relative bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700 select-none text-center" style={{ width: columnWidth ? `${columnWidth}px` : undefined, userSelect: 'none' @@ -2067,11 +2067,18 @@ export const InteractiveDataTable: React.FC = ({ /> )} - {visibleColumns.map((column: DataTableColumn) => ( - - {formatCellValue(row[column.columnName], column, row)} - - ))} + {visibleColumns.map((column: DataTableColumn) => { + const isNumeric = column.widgetType === "number" || column.widgetType === "decimal"; + return ( + + {formatCellValue(row[column.columnName], column, row)} + + ); + })} {/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */} )) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 780b4c4d..aa82d5b2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1067,7 +1067,7 @@ export const TableListComponent: React.FC = ({ column.sortable && "cursor-pointer" )} style={{ - textAlign: column.columnName === "__checkbox__" ? "center" : (column.align || "left"), + textAlign: column.columnName === "__checkbox__" ? "center" : "center", width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined), minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, @@ -1191,6 +1191,10 @@ export const TableListComponent: React.FC = ({ const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const cellValue = row[mappedColumnName]; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + return ( = ({ column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3" )} style={{ - textAlign: column.columnName === "__checkbox__" ? "center" : (column.align || "left"), + textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")), width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, From f9bd7bbcb323b3a6c8ce36f235dced99a97f9aa1 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 13:30:44 +0900 Subject: [PATCH 38/76] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=8B=9C=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=EC=9D=B4=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 컬럼 너비를 조절할 때 자동으로 정렬이 실행됨 - 리사이즈 핸들 클릭이 헤더의 onClick 이벤트를 트리거 해결: - isResizing useRef 플래그 추가 - 리사이즈 시작 시 플래그를 true로 설정 - 헤더 onClick에서 isResizing.current 체크 - 리사이즈 중이면 정렬 실행 안함 - mouseup 후 100ms 지연으로 플래그 해제 적용: - TableListComponent - InteractiveDataTable 이제 컬럼 리사이즈가 정렬을 방해하지 않음 --- frontend/components/screen/InteractiveDataTable.tsx | 8 ++++++++ .../components/table-list/TableListComponent.tsx | 13 ++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 45a5e488..54c4be4b 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -109,6 +109,7 @@ export const InteractiveDataTable: React.FC = ({ const [columnWidths, setColumnWidths] = useState>({}); const hasInitializedWidthsRef = useRef(false); const columnRefs = useRef>({}); + const isResizingRef = useRef(false); // SaveModal 상태 (등록/수정 통합) const [showSaveModal, setShowSaveModal] = useState(false); @@ -1994,6 +1995,8 @@ export const InteractiveDataTable: React.FC = ({ const thElement = columnRefs.current[column.id]; if (!thElement) return; + isResizingRef.current = true; + const startX = e.clientX; const startWidth = columnWidth || thElement.offsetWidth; @@ -2024,6 +2027,11 @@ export const InteractiveDataTable: React.FC = ({ document.body.style.userSelect = ''; document.body.style.cursor = ''; + // 약간의 지연 후 리사이즈 플래그 해제 + setTimeout(() => { + isResizingRef.current = false; + }, 100); + document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index aa82d5b2..a1254d18 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -248,6 +248,7 @@ export const TableListComponent: React.FC = ({ const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); const hasInitializedWidths = useRef(false); + const isResizing = useRef(false); // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); @@ -1073,7 +1074,10 @@ export const TableListComponent: React.FC = ({ maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, userSelect: 'none' }} - onClick={() => column.sortable && handleSort(column.columnName)} + onClick={() => { + if (isResizing.current) return; + if (column.sortable) handleSort(column.columnName); + }} > {column.columnName === "__checkbox__" ? ( renderCheckboxHeader() @@ -1098,6 +1102,8 @@ export const TableListComponent: React.FC = ({ const thElement = columnRefs.current[column.columnName]; if (!thElement) return; + isResizing.current = true; + const startX = e.clientX; const startWidth = columnWidth || thElement.offsetWidth; @@ -1128,6 +1134,11 @@ export const TableListComponent: React.FC = ({ document.body.style.userSelect = ''; document.body.style.cursor = ''; + // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) + setTimeout(() => { + isResizing.current = false; + }, 100); + document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; From 40efb391ea124a122b670e5fbf632d3230ae0c75 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 13:33:13 +0900 Subject: [PATCH 39/76] =?UTF-8?q?feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20=EA=B7=B8=EB=9D=BC=EB=8D=B0=EC=9D=B4=EC=85=98?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD,=20=EA=B5=B5=EC=9D=80=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC,=20=ED=98=B8=EB=B2=84=20=ED=9A=A8=EA=B3=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/InteractiveDataTable.tsx | 4 ++-- .../registry/components/table-list/TableListComponent.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 54c4be4b..ca6facb6 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1954,7 +1954,7 @@ export const InteractiveDataTable: React.FC = ({ <>
- + {/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( @@ -1975,7 +1975,7 @@ export const InteractiveDataTable: React.FC = ({ (columnRefs.current[column.id] = el)} - className="relative bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700 select-none text-center" + className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors" style={{ width: columnWidth ? `${columnWidth}px` : undefined, userSelect: 'none' diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index a1254d18..598e8017 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1052,9 +1052,9 @@ export const TableListComponent: React.FC = ({ > {/* 헤더 (sticky) */} - + {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[column.columnName]; @@ -1063,9 +1063,9 @@ export const TableListComponent: React.FC = ({ key={column.columnName} ref={(el) => (columnRefs.current[column.columnName] = el)} className={cn( - "relative h-10 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background select-none sm:h-12 sm:text-sm sm:whitespace-nowrap", + "relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis select-none sm:h-12 sm:text-sm sm:whitespace-nowrap", column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3", - column.sortable && "cursor-pointer" + column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors" )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", From 714511c3cf3343ac929cbf4365630ac95f74a169 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 13:34:02 +0900 Subject: [PATCH 40/76] =?UTF-8?q?fix:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88=20=EB=AC=B8=EC=A0=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20-=20=EB=AA=A8=EB=93=A0=20=EC=85=80=EC=97=90=20white?= =?UTF-8?q?space-nowrap=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 헤더와 데이터 셀 모두에 whitespace-nowrap 적용 - 모바일에서도 텍스트가 2줄로 깨지지 않음 - overflow-hidden + text-ellipsis로 긴 텍스트 처리 - TableListComponent와 InteractiveDataTable 모두 적용 이제 화면을 줄여도 텍스트가 항상 1줄로 유지됨 --- frontend/components/screen/InteractiveDataTable.tsx | 2 +- .../lib/registry/components/table-list/TableListComponent.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index ca6facb6..fa90bd2d 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -2080,7 +2080,7 @@ export const InteractiveDataTable: React.FC = ({ return ( {formatCellValue(row[column.columnName], column, row)} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 598e8017..6a3cb420 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1063,7 +1063,7 @@ export const TableListComponent: React.FC = ({ key={column.columnName} ref={(el) => (columnRefs.current[column.columnName] = el)} className={cn( - "relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis select-none sm:h-12 sm:text-sm sm:whitespace-nowrap", + "relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm", column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3", column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors" )} @@ -1210,7 +1210,7 @@ export const TableListComponent: React.FC = ({ + ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.map((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + return ( + + {/* 그룹 헤더 */} + + + + {/* 그룹 데이터 */} + {!isCollapsed && + group.items.map((row, index) => ( + handleRowDragStart(e, row, index)} + onDragEnd={handleRowDragEnd} + className={cn( + "h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16" + )} + onClick={() => handleRowClick(row)} + > + {visibleColumns.map((column) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const cellValue = row[mappedColumnName]; + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + ); + })} + + ))} + + ); + }) ) : ( + // 일반 렌더링 (그룹 없음) data.map((row, index) => ( = ({ + + {/* 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); }; diff --git a/테이블_그룹핑_기능_구현_계획서.md b/테이블_그룹핑_기능_구현_계획서.md new file mode 100644 index 00000000..b6b86afa --- /dev/null +++ b/테이블_그룹핑_기능_구현_계획서.md @@ -0,0 +1,365 @@ +# 테이블 그룹핑 기능 구현 계획서 + +## 📋 개요 + +테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다. + +## 🎯 핵심 요구사항 + +### 1. 기능 요구사항 +- ✅ 그룹핑할 컬럼을 다중 선택 가능 +- ✅ 선택한 컬럼 순서대로 계층적 그룹화 +- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시 +- ✅ 그룹 펼치기/접기 기능 +- ✅ localStorage에 그룹 설정 저장/복원 +- ✅ 그룹 해제 기능 + +### 2. 적용 대상 +- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`) +- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`) + +## 🎨 UI 디자인 + +### 그룹 설정 다이얼로그 + +```tsx +┌─────────────────────────────────────┐ +│ 📊 그룹 설정 │ +│ 데이터를 그룹화할 컬럼을 선택하세요 │ +├─────────────────────────────────────┤ +│ │ +│ [x] 통화 │ +│ [ ] 단위 │ +│ [ ] 품목코드 │ +│ [ ] 품목명 │ +│ [ ] 규격 │ +│ │ +│ 💡 선택된 그룹: 통화 │ +│ │ +├─────────────────────────────────────┤ +│ [취소] [적용] │ +└─────────────────────────────────────┘ +``` + +### 그룹화된 테이블 표시 + +```tsx +┌─────────────────────────────────────────────────────┐ +│ 📦 판매품목 목록 총 3개 [🎨 그룹: 통화 ×] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ ▼ 통화: KRW > 단위: EA (2건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-001 │ 볼트 M8x20 │ M8x20 │ EA │ │ +│ │ SALE-004 │ 스프링 와셔 │ M10 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ▼ 통화: USD > 단위: EA (1건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-002 │ 너트 M8 │ M8 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +## 🔧 기술 구현 + +### 1. 상태 관리 + +```typescript +// 그룹 설정 관련 상태 +const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 +const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 +const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 +``` + +### 2. 데이터 그룹화 로직 + +```typescript +interface GroupedData { + groupKey: string; // "통화:KRW > 단위:EA" + groupValues: Record; // { 통화: "KRW", 단위: "EA" } + items: any[]; // 그룹에 속한 데이터 + count: number; // 항목 개수 +} + +const groupDataByColumns = ( + data: any[], + groupColumns: string[] +): GroupedData[] => { + if (groupColumns.length === 0) return []; + + const grouped = new Map(); + + data.forEach(item => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`); + const groupKey = keyParts.join(' > '); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupColumns.forEach(col => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); +}; +``` + +### 3. localStorage 저장/로드 + +```typescript +// 저장 키 +const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `table-list-group-${tableConfig.selectedTable}`; +}, [tableConfig.selectedTable]); + +// 그룹 설정 저장 +const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } +}, [groupSettingKey, groupByColumns]); + +// 그룹 설정 로드 +useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } +}, [groupSettingKey, visibleColumns]); +``` + +### 4. 그룹 헤더 렌더링 + +```tsx +const renderGroupHeader = (group: GroupedData) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + + return ( +
toggleGroupCollapse(group.groupKey)} + > + {/* 펼치기/접기 아이콘 */} + {isCollapsed ? ( + + ) : ( + + )} + + {/* 그룹 정보 */} + + {groupByColumns.map((col, idx) => ( + + {idx > 0 && > } + {columnLabels[col] || col}: + {" "} + {group.groupValues[col]} + + ))} + + + {/* 항목 개수 */} + + ({group.count}건) + +
+ ); +}; +``` + +### 5. 그룹 설정 다이얼로그 + +```tsx + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹: + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
+``` + +### 6. 그룹 해제 버튼 + +```tsx +{/* 헤더 영역 */} +
+

{tableLabel}

+
+ {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+ 그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")} + +
+ )} + + {/* 그룹 설정 버튼 */} + +
+
+``` + +## 📝 구현 순서 + +### Phase 1: TableListComponent 구현 +1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups) +2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수) +3. ✅ localStorage 저장/로드 로직 +4. ✅ 그룹 설정 다이얼로그 UI +5. ✅ 그룹 헤더 렌더링 +6. ✅ 그룹별 데이터 렌더링 +7. ✅ 그룹 해제 기능 + +### Phase 2: FlowWidget 구현 +1. ✅ TableListComponent와 동일한 로직 적용 +2. ✅ 스텝 데이터에 그룹화 적용 +3. ✅ UI 통일성 유지 + +### Phase 3: 테스트 및 최적화 +1. ✅ 다중 그룹 계층 테스트 +2. ✅ 대량 데이터 성능 테스트 +3. ✅ localStorage 저장/복원 테스트 +4. ✅ 그룹 펼치기/접기 테스트 + +## 🎯 예상 효과 + +### 사용자 경험 개선 +- 데이터를 논리적으로 그룹화하여 가독성 향상 +- 대량 데이터를 효율적으로 탐색 가능 +- 사용자 정의 뷰 제공 + +### 데이터 분석 지원 +- 카테고리별 데이터 분석 용이 +- 통계 정보 제공 (그룹별 개수) +- 계층적 데이터 구조 시각화 + +## ⚠️ 주의사항 + +### 성능 고려사항 +- 그룹화는 클라이언트 측에서 수행 +- 대량 데이터의 경우 성능 저하 가능 +- 필요시 서버 측 그룹화로 전환 검토 + +### 사용성 +- 그룹화 해제가 쉽게 가능해야 함 +- 그룹 설정이 직관적이어야 함 +- 모바일에서도 사용 가능한 UI + +## 📊 구현 상태 + +- [ ] Phase 1: TableListComponent 구현 + - [ ] 상태 관리 추가 + - [ ] 그룹화 로직 구현 + - [ ] localStorage 연동 + - [ ] UI 구현 +- [ ] Phase 2: FlowWidget 구현 +- [ ] Phase 3: 테스트 및 최적화 + +--- + +**작성일**: 2025-11-03 +**버전**: 1.0 +**상태**: 구현 예정 + From eb9c85f786f9f026ab2250df4db913de0ac0211a Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:13:12 +0900 Subject: [PATCH 47/76] =?UTF-8?q?feat:=20FlowWidget=EC=97=90=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=ED=95=91=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableListComponent와 동일한 그룹핑 기능 적용 - 다중 컬럼 선택으로 계층적 그룹화 지원 - 그룹 설정 다이얼로그 추가 - 그룹별 데이터 펼치기/접기 기능 - 그룹 헤더에 항목 개수 표시 - localStorage에 그룹 설정 저장/복원 - 그룹 해제 버튼 추가 - 그룹 표시 배지 UI 주요 기능: - 플로우 스텝 데이터에 그룹화 적용 - filteredData와 stepData 모두 지원 - 그룹 없을 때는 기존 페이지네이션 유지 - 그룹 있을 때는 모든 그룹 데이터 표시 --- .../components/screen/widgets/FlowWidget.tsx | 408 ++++++++++++++---- 1 file changed, 334 insertions(+), 74 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 9cb0a207..4bf23ba4 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react"; +import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react"; import { getFlowById, getAllStepCounts, @@ -40,6 +40,14 @@ import { Input } from "@/components/ui/input"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + interface FlowWidgetProps { component: FlowComponent; onStepClick?: (stepId: number, stepName: string) => void; @@ -106,6 +114,11 @@ export function FlowWidget({ const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + // 🆕 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 + const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 + /** * 🆕 컬럼 표시 결정 함수 * 1순위: 플로우 스텝 기본 설정 (displayConfig) @@ -163,43 +176,30 @@ export function FlowWidget({ // 초기값: 빈 필터 (사용자가 선택해야 함) setSearchFilterColumns(new Set()); } - - // 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거) - if (typeof window !== "undefined") { - const currentUserId = user.userId; - const keysToRemove: string[] = []; - - // localStorage의 모든 키를 확인 - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith("flowWidget_searchFilters_")) { - // 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId} - // split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"] - // 따라서 userId는 parts[2]입니다 - const parts = key.split("_"); - if (parts.length >= 3) { - const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId - // 현재 사용자 ID와 다른 사용자의 설정은 제거 - if (userIdFromKey !== currentUserId) { - keysToRemove.push(key); - } - } - } - } - - // 이전 사용자의 설정 제거 - if (keysToRemove.length > 0) { - keysToRemove.forEach(key => { - localStorage.removeItem(key); - }); - } - } } catch (error) { console.error("필터 설정 불러오기 실패:", error); setSearchFilterColumns(new Set()); } }, [filterSettingKey, stepDataColumns, user?.userId]); + // 🆕 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || stepDataColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + // 현재 단계에 표시되는 컬럼만 필터링 + const validGroups = savedGroups.filter((col: string) => stepDataColumns.includes(col)); + setGroupByColumns(validGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + setGroupByColumns([]); + } + }, [groupSettingKey, stepDataColumns]); + // 🆕 필터 설정 저장 const saveFilterSettings = useCallback(() => { if (!filterSettingKey) return; @@ -247,6 +247,98 @@ export function FlowWidget({ setFilteredData([]); }, []); + // 🆕 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!selectedStep) return null; + return `flowWidget_groupSettings_step_${selectedStep}`; + }, [selectedStep]); + + // 🆕 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 🆕 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 🆕 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 🆕 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 🆕 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + const dataToGroup = filteredData.length > 0 ? filteredData : stepData; + + if (groupByColumns.length === 0 || dataToGroup.length === 0) return []; + + const grouped = new Map(); + + dataToGroup.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [filteredData, stepData, groupByColumns, columnLabels]); + // 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리) useEffect(() => { if (!stepData || stepData.length === 0) { @@ -796,29 +888,82 @@ export function FlowWidget({ {/* 🆕 필터 설정 버튼 */} {stepDataColumns.length > 0 && ( - +
+ + +
)} + {/* 🆕 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ +
+
+ )} + {/* 🆕 검색 필터 입력 영역 */} {searchFilterColumns.size > 0 && (
@@ -940,29 +1085,87 @@ export function FlowWidget({ - {paginatedStepData.map((row, pageIndex) => { - const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> + {groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.flatMap((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + const groupRows = [ + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
- )} - {stepDataColumns.map((col) => ( - - {formatValue(row[col])} - - ))} -
- ); - })} +
, + ]; + + if (!isCollapsed) { + const dataRows = group.items.map((row, itemIndex) => { + const actualIndex = displayData.indexOf(row); + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }); + groupRows.push(...dataRows); + } + + return groupRows; + }) + ) : ( + // 일반 렌더링 (그룹 없음) + paginatedStepData.map((row, pageIndex) => { + const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }) + )}
Date: Mon, 3 Nov 2025 13:51:08 +0900 Subject: [PATCH 41/76] =?UTF-8?q?feat:=20DataTableTemplate=EC=97=90=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=9C=84=EC=A0=AF=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EA=B2=80=EC=83=89=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 새로운 기능 - 플로우 위젯과 동일한 검색 필터 설정 기능 구현 - 사용자가 원하는 컬럼만 선택하여 검색 가능 - localStorage 기반 필터 설정 저장/복원 🎨 UI 추가 - '검색 필터 설정' 버튼 (FlowWidget 스타일) - 선택된 컬럼의 동적 검색 입력 필드 - 필터 개수 뱃지 표시 - 체크박스 기반 필터 설정 다이얼로그 🔧 기술적 구현 - searchFilterColumns 상태로 선택된 컬럼 관리 - searchValues 상태로 각 컬럼별 검색값 관리 - useAuth 훅으로 사용자별 필터 설정 저장 - Grid 레이아웃으로 검색 필드 반응형 배치 📝 변경된 파일 - frontend/components/screen/templates/DataTableTemplate.tsx ✅ 테스트 완료 - 필터 설정 저장/복원 - 동적 검색 필드 생성 - 반응형 레이아웃 - 미리보기 모드에서 비활성화 --- .../screen/templates/DataTableTemplate.tsx | 154 +++++++++++++++++- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/frontend/components/screen/templates/DataTableTemplate.tsx b/frontend/components/screen/templates/DataTableTemplate.tsx index 86422e7f..b24f27c3 100644 --- a/frontend/components/screen/templates/DataTableTemplate.tsx +++ b/frontend/components/screen/templates/DataTableTemplate.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,9 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; /** * 데이터 테이블 템플릿 컴포넌트 @@ -121,6 +124,13 @@ export const DataTableTemplate: React.FC = ({ className = "", isPreview = true, }) => { + const { user } = useAuth(); + + // 🆕 검색 필터 관련 상태 + const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); + const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); + const [searchValues, setSearchValues] = useState>({}); + // 설정된 컬럼만 사용 (자동 생성 안함) const defaultColumns = React.useMemo(() => { return columns || []; @@ -138,6 +148,54 @@ export const DataTableTemplate: React.FC = ({ }, [isPreview]); const visibleColumns = defaultColumns.filter((col) => col.visible); + + // 🆕 컬럼명 -> 라벨 매핑 + const columnLabels = React.useMemo(() => { + const labels: Record = {}; + defaultColumns.forEach(col => { + labels[col.id] = col.label; + }); + return labels; + }, [defaultColumns]); + + // 🆕 localStorage에서 필터 설정 복원 + useEffect(() => { + if (user?.userId && title) { + const storageKey = `datatable-search-filter-${user.userId}-${title}`; + const savedFilter = localStorage.getItem(storageKey); + if (savedFilter) { + try { + const parsed = JSON.parse(savedFilter); + setSearchFilterColumns(new Set(parsed)); + } catch (e) { + console.error("필터 설정 복원 실패:", e); + } + } + } + }, [user?.userId, title]); + + // 🆕 필터 저장 함수 + const handleSaveSearchFilter = useCallback(() => { + if (user?.userId && title) { + const storageKey = `datatable-search-filter-${user.userId}-${title}`; + const filterArray = Array.from(searchFilterColumns); + localStorage.setItem(storageKey, JSON.stringify(filterArray)); + toast.success("검색 필터 설정이 저장되었습니다."); + } + }, [user?.userId, title, searchFilterColumns]); + + // 🆕 필터 토글 함수 + const handleToggleFilterColumn = useCallback((columnId: string) => { + setSearchFilterColumns((prev) => { + const newSet = new Set(prev); + if (newSet.has(columnId)) { + newSet.delete(columnId); + } else { + newSet.add(columnId); + } + return newSet; + }); + }, []); return ( @@ -178,23 +236,65 @@ export const DataTableTemplate: React.FC = ({ + {/* 🆕 검색 필터 설정 버튼 영역 */} + {defaultColumns.length > 0 && ( +
+ +
+ )} + + {/* 🆕 선택된 컬럼의 검색 입력 필드 */} + {searchFilterColumns.size > 0 && ( +
+ {Array.from(searchFilterColumns).map((columnId) => { + const column = defaultColumns.find(col => col.id === columnId); + if (!column) return null; + + return ( +
+ + setSearchValues(prev => ({...prev, [columnId]: e.target.value}))} + disabled={isPreview} + className="h-9 text-sm" + /> +
+ ); + })} +
+ )} + {/* 검색 및 필터 영역 */}
{/* 검색 입력 */}
-
- - -
- {actions.showSearchButton && ( )}
- {/* 필터 영역 */} + {/* 기존 필터 영역 (이제는 사용하지 않음) */} {filters.length > 0 && (
@@ -352,6 +452,46 @@ export const DataTableTemplate: React.FC = ({
)} + + {/* 🆕 검색 필터 설정 다이얼로그 */} + + + + 검색 필터 설정 + + 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다. + + + +
+ {defaultColumns.map((column) => ( +
+ handleToggleFilterColumn(column.id)} + /> + +
+ ))} +
+ + + + + +
+
); }; From e0e7bc387ef254dd52b54c7833627cb16bad2403 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 13:53:50 +0900 Subject: [PATCH 42/76] =?UTF-8?q?fix:=20TableListComponent=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎨 UI 개선 - 필터 설정 다이얼로그 스타일을 플로우 위젯과 동일하게 변경 - hover:bg-gray-50으로 호버 효과 추가 - rounded-lg, p-3, space-x-3으로 간격 및 패딩 개선 - space-y-2, py-4로 리스트 아이템 간격 조정 🐛 버그 수정 - 필터 목록에 key prop 추가 (React 경고 해결) - Label 컴포넌트 대신 label 태그 사용 (불필요한 import 제거) 📝 변경 사항 - 플로우 위젯과 동일한 체크박스 리스트 스타일 적용 - 더 명확하고 클릭하기 쉬운 UI로 개선 ✅ 결과 - 필터 설정 다이얼로그가 플로우 위젯과 일관된 스타일로 표시됨 --- .../components/table-list/TableListComponent.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6a3cb420..7249eb6c 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1248,20 +1248,23 @@ export const TableListComponent: React.FC = ({ -
+
{(tableConfig.filter?.filters || []).map((filter) => ( -
+
toggleFilterVisibility(filter.columnName)} /> - +
))}
From 297870a24c46b89ab4ddb7cf78c018e92822d60e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 13:59:12 +0900 Subject: [PATCH 43/76] =?UTF-8?q?feat:=20TableListComponent=EC=97=90=20Flo?= =?UTF-8?q?wWidget=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 선택/해제 기능 추가 - 선택된 컬럼 개수 표시 추가 - 필터 설정 localStorage 저장/로드 기능 - 체크된 항목만 실제 검색 필터로 표시 - 저장 시 Toast 알림 추가 - FlowWidget과 완전히 동일한 UI/UX 적용 --- .../screen/InteractiveDataTable.tsx | 60 ++++++++- .../table-list/TableListComponent.tsx | 126 +++++++++++++----- 2 files changed, 153 insertions(+), 33 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index fa90bd2d..e05cc973 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -38,10 +38,12 @@ import { Folder, FolderOpen, Grid, + Filter, } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { cn } from "@/lib/utils"; import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file"; @@ -99,6 +101,7 @@ export const InteractiveDataTable: React.FC = ({ onRefresh, }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const { user } = useAuth(); // 사용자 정보 가져오기 const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [searchValues, setSearchValues] = useState>({}); @@ -134,6 +137,13 @@ export const InteractiveDataTable: React.FC = ({ // 공통코드 관리 상태 const [codeOptions, setCodeOptions] = useState>>({}); + // 🆕 검색 필터 관련 상태 (FlowWidget과 동일) + const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); // 검색 필터로 사용할 컬럼 + const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그 + const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 + const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 + // 공통코드 옵션 가져오기 const loadCodeOptions = useCallback( async (categoryCode: string) => { @@ -633,6 +643,31 @@ export const InteractiveDataTable: React.FC = ({ try { const columns = await tableTypeApi.getColumns(component.tableName); setTableColumns(columns); + + // 🆕 전체 컬럼 목록 설정 + const columnNames = columns.map(col => col.columnName); + setAllAvailableColumns(columnNames); + + // 🆕 컬럼명 -> 라벨 매핑 생성 + const labels: Record = {}; + columns.forEach(col => { + labels[col.columnName] = col.displayName || col.columnName; + }); + setColumnLabels(labels); + + // 🆕 localStorage에서 필터 설정 복원 + if (user?.userId && component.componentId) { + const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; + const savedFilter = localStorage.getItem(storageKey); + if (savedFilter) { + try { + const parsed = JSON.parse(savedFilter); + setSearchFilterColumns(new Set(parsed)); + } catch (e) { + console.error("필터 설정 복원 실패:", e); + } + } + } } catch (error) { // console.error("테이블 컬럼 정보 로드 실패:", error); } @@ -641,7 +676,7 @@ export const InteractiveDataTable: React.FC = ({ if (component.tableName) { fetchTableColumns(); } - }, [component.tableName]); + }, [component.tableName, component.componentId, user?.userId]); // 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함) const searchFilters = useMemo(() => { @@ -1052,6 +1087,29 @@ export const InteractiveDataTable: React.FC = ({ } }, [isAdding]); + // 🆕 검색 필터 저장 함수 + const handleSaveSearchFilter = useCallback(() => { + if (user?.userId && component.componentId) { + const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; + const filterArray = Array.from(searchFilterColumns); + localStorage.setItem(storageKey, JSON.stringify(filterArray)); + toast.success("검색 필터 설정이 저장되었습니다."); + } + }, [user?.userId, component.componentId, searchFilterColumns]); + + // 🆕 검색 필터 토글 함수 + const handleToggleFilterColumn = useCallback((columnName: string) => { + setSearchFilterColumns((prev) => { + const newSet = new Set(prev); + if (newSet.has(columnName)) { + newSet.delete(columnName); + } else { + newSet.add(columnName); + } + return newSet; + }); + }, []); + // 데이터 삭제 핸들러 const handleDeleteData = useCallback(() => { if (selectedRows.size === 0) { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 7249eb6c..b5b0700d 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -253,6 +253,12 @@ export const TableListComponent: React.FC = ({ // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + + // 필터 설정 키 생성 + const filterSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `table-list-filter-${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, @@ -716,7 +722,7 @@ export const TableListComponent: React.FC = ({ // 저장된 필터 설정 불러오기 useEffect(() => { - if (!filterSettingKey) return; + if (!filterSettingKey || visibleColumns.length === 0) return; try { const saved = localStorage.getItem(filterSettingKey); @@ -724,17 +730,14 @@ export const TableListComponent: React.FC = ({ const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { - // 초기값: 모든 필터 표시 - const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); - setVisibleFilterColumns(new Set(allFilters)); + // 초기값: 빈 Set (아무것도 선택 안 함) + setVisibleFilterColumns(new Set()); } } catch (error) { console.error("필터 설정 불러오기 실패:", error); - // 기본값으로 모든 필터 표시 - const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); - setVisibleFilterColumns(new Set(allFilters)); + setVisibleFilterColumns(new Set()); } - }, [filterSettingKey, tableConfig.filter?.filters]); + }, [filterSettingKey, visibleColumns]); // 필터 설정 저장 const saveFilterSettings = useCallback(() => { @@ -743,12 +746,17 @@ export const TableListComponent: React.FC = ({ try { localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); + toast.success("검색 필터 설정이 저장되었습니다"); + + // 검색 값 초기화 + setSearchValues({}); } catch (error) { console.error("필터 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); } }, [filterSettingKey, visibleFilterColumns]); - // 필터 토글 + // 필터 컬럼 토글 const toggleFilterVisibility = useCallback((columnName: string) => { setVisibleFilterColumns((prev) => { const newSet = new Set(prev); @@ -761,10 +769,30 @@ export const TableListComponent: React.FC = ({ }); }, []); - // 표시할 필터 목록 + // 전체 선택/해제 + const toggleAllFilters = useCallback(() => { + const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + const columnNames = filterableColumns.map((col) => col.columnName); + + if (visibleFilterColumns.size === columnNames.length) { + // 전체 해제 + setVisibleFilterColumns(new Set()); + } else { + // 전체 선택 + setVisibleFilterColumns(new Set(columnNames)); + } + }, [visibleFilterColumns, visibleColumns]); + + // 표시할 필터 목록 (선택된 컬럼만) const activeFilters = useMemo(() => { - return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName)); - }, [tableConfig.filter?.filters, visibleFilterColumns]); + return visibleColumns + .filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName)) + .map((col) => ({ + columnName: col.columnName, + label: columnLabels[col.columnName] || col.displayName || col.columnName, + type: col.format || "text", + })); + }, [visibleColumns, visibleFilterColumns, columnLabels]); useEffect(() => { fetchColumnLabels(); @@ -1244,29 +1272,63 @@ export const TableListComponent: React.FC = ({ 검색 필터 설정 - 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다. + 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. -
- {(tableConfig.filter?.filters || []).map((filter) => ( -
- toggleFilterVisibility(filter.columnName)} - /> - -
- ))} +
+ {/* 전체 선택/해제 */} +
+ col.columnName !== "__checkbox__").length && + visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0 + } + onCheckedChange={toggleAllFilters} + /> + + + {visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length} + 개 + +
+ + {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleFilterVisibility(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 컬럼 개수 안내 */} +
+ {visibleFilterColumns.size === 0 ? ( + 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 + ) : ( + + 총 {visibleFilterColumns.size}개의 검색 필터가 + 표시됩니다 + + )} +
From c7db82a8a579c33a623f287e9bd0ba509ee1b495 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:00:06 +0900 Subject: [PATCH 44/76] =?UTF-8?q?fix:=20TableListComponent=20filterSetting?= =?UTF-8?q?Key=20=EC=A4=91=EB=B3=B5=20=EC=A0=95=EC=9D=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중복된 filterSettingKey 변수 정의 중 하나 제거 - 빌드 에러 해결 --- .../registry/components/table-list/TableListComponent.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index b5b0700d..f9876d28 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -253,12 +253,6 @@ export const TableListComponent: React.FC = ({ // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); - - // 필터 설정 키 생성 - const filterSettingKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - return `table-list-filter-${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, From 8248c8dc963de8e2fff757801dea2dc8698b3d30 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:00:45 +0900 Subject: [PATCH 45/76] =?UTF-8?q?fix:=20TableListComponent=EC=97=90=20toas?= =?UTF-8?q?t=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sonner의 toast 함수 import 추가 - 필터 설정 저장 시 알림 기능 정상 작동 --- .../lib/registry/components/table-list/TableListComponent.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index f9876d28..367f95e0 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -22,6 +22,7 @@ import { } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { Dialog, DialogContent, From b607ef0aa03a4662b71612e25b90d263b35d545a Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:08:26 +0900 Subject: [PATCH 46/76] =?UTF-8?q?feat:=20TableListComponent=EC=97=90=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=ED=95=91=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다중 컬럼 선택으로 계층적 그룹화 지원 - 그룹 설정 다이얼로그 추가 - 그룹별 데이터 펼치기/접기 기능 - 그룹 헤더에 항목 개수 표시 - localStorage에 그룹 설정 저장/복원 - 그룹 해제 버튼 추가 - 그룹 표시 배지 UI 주요 기능: - 사용자가 원하는 컬럼(들)을 선택하여 그룹화 - 그룹 키: '통화:KRW > 단위:EA' 형식으로 표시 - 그룹 헤더 클릭으로 펼치기/접기 - 그룹 없을 때는 기존 렌더링 방식 유지 --- .../table-list/TableListComponent.tsx | 366 +++++++++++++++++- 테이블_그룹핑_기능_구현_계획서.md | 365 +++++++++++++++++ 2 files changed, 715 insertions(+), 16 deletions(-) create mode 100644 테이블_그룹핑_기능_구현_계획서.md diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 367f95e0..9ee28aac 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -19,6 +19,8 @@ import { TableIcon, Settings, X, + Layers, + ChevronDown, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; @@ -36,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; +// ======================================== +// 인터페이스 +// ======================================== + +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + // ======================================== // 캐시 및 유틸리티 // ======================================== @@ -255,6 +269,11 @@ export const TableListComponent: React.FC = ({ const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); + const [groupByColumns, setGroupByColumns] = useState([]); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, @@ -715,6 +734,12 @@ export const TableListComponent: React.FC = ({ return `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable]); + // 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableList_groupSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + // 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || visibleColumns.length === 0) return; @@ -789,6 +814,105 @@ export const TableListComponent: React.FC = ({ })); }, [visibleColumns, visibleFilterColumns, columnLabels]); + // 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + if (groupByColumns.length === 0 || data.length === 0) return []; + + const grouped = new Map(); + + data.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [data, groupByColumns, columnLabels]); + + // 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } + }, [groupSettingKey, visibleColumns]); + useEffect(() => { fetchColumnLabels(); fetchTableLabel(); @@ -980,15 +1104,52 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} />
- + +
+
+
+ )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} @@ -1045,15 +1206,52 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} /> - + + + + + )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} @@ -1209,7 +1407,81 @@ export const TableListComponent: React.FC = ({
+
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
+
+ {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(cellValue, column, row)} +
@@ -1162,6 +1365,63 @@ export function FlowWidget({ + + {/* 🆕 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {stepDataColumns.map((col) => ( +
+ toggleGroupColumn(col)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); } From 71f38a38e0ee541baf5c6e71385bbe03d178e797 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:19:13 +0900 Subject: [PATCH 48/76] =?UTF-8?q?fix:=20FlowWidget=20groupSettingKey=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectedStep -> selectedStepId로 수정 - groupSettingKey 선언 위치를 useEffect 이전으로 이동 - 중복 선언 제거 --- frontend/components/screen/widgets/FlowWidget.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 4bf23ba4..0caa97e4 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -160,6 +160,12 @@ export function FlowWidget({ return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`; }, [flowId, selectedStepId, user?.userId]); + // 🆕 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!selectedStepId) return null; + return `flowWidget_groupSettings_step_${selectedStepId}`; + }, [selectedStepId]); + // 🆕 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return; @@ -247,12 +253,6 @@ export function FlowWidget({ setFilteredData([]); }, []); - // 🆕 그룹 설정 localStorage 키 생성 - const groupSettingKey = useMemo(() => { - if (!selectedStep) return null; - return `flowWidget_groupSettings_step_${selectedStep}`; - }, [selectedStep]); - // 🆕 그룹 설정 저장 const saveGroupSettings = useCallback(() => { if (!groupSettingKey) return; From ac40f0227eb41a679ac601717be351ba7b3b8690 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:23:53 +0900 Subject: [PATCH 49/76] =?UTF-8?q?refactor:=20TableListComponent=EC=99=80?= =?UTF-8?q?=20FlowWidget=20=EC=83=81=EB=8B=A8=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableListComponent: showHeader 조건부 렌더링 제거 - 타이틀 표시 영역 삭제 - 공간 절약을 위해 헤더 완전 제거 - FlowWidget: 플로우 제목 및 설명 영역 제거 - flowData.name 표시 영역 삭제 - flowData.description 표시 영역 삭제 - 더 많은 데이터 표시 공간 확보 UI 개선: - 불필요한 헤더 제거로 컨텐츠 영역 확대 - 더 많은 데이터를 한 화면에 표시 가능 --- .../components/screen/widgets/FlowWidget.tsx | 11 ----------- .../table-list/TableListComponent.tsx | 17 ----------------- 2 files changed, 28 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 0caa97e4..d3aca16f 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -771,17 +771,6 @@ export function FlowWidget({ return (
- {/* 플로우 제목 */} -
-
-

{flowData.name}

-
- - {flowData.description && ( -

{flowData.description}

- )} -
- {/* 플로우 스텝 목록 */}
{steps.map((step, index) => ( diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9ee28aac..365c1bc5 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1084,14 +1084,6 @@ export const TableListComponent: React.FC = ({ if (tableConfig.stickyHeader && !isDesignMode) { return (
- {tableConfig.showHeader && ( -
-

- {tableConfig.title || tableLabel || finalSelectedTable} -

-
- )} - {tableConfig.filter?.enabled && (
@@ -1184,15 +1176,6 @@ export const TableListComponent: React.FC = ({ return ( <>
- {/* 헤더 */} - {tableConfig.showHeader && ( -
-

- {tableConfig.title || tableLabel || finalSelectedTable} -

-
- )} - {/* 필터 */} {tableConfig.filter?.enabled && (
From 4386a009a42c940a8e7dcec67c444dc3c20ef52f Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:25:53 +0900 Subject: [PATCH 50/76] =?UTF-8?q?refactor:=20FlowWidget=20=EC=8A=A4?= =?UTF-8?q?=ED=85=9D=20=EC=B9=B4=EB=93=9C=EC=97=90=EC=84=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=85=9D=20=EC=9D=B4=EB=A6=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스텝 이름(stepName) 표시 영역 완전 제거 - 데이터 건수만 표시하도록 간소화 - 하단 선택 바와 건수만으로 스텝 구분 - 패딩 조정 (pb-5 -> pb-3, pb-6 -> pb-4) UI 개선: - 스텝 영역 높이 감소로 공간 절약 - 더 많은 데이터 표시 공간 확보 - 시각적으로 더 간결한 인터페이스 --- frontend/components/screen/widgets/FlowWidget.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index d3aca16f..b40e2358 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -781,16 +781,7 @@ export function FlowWidget({ onClick={() => handleStepClick(step.id, step.stepName)} > {/* 콘텐츠 */} -
- {/* 스텝 이름 */} -

- {step.stepName} -

- +
{/* 데이터 건수 */} {showStepCount && (
Date: Mon, 3 Nov 2025 14:28:03 +0900 Subject: [PATCH 51/76] =?UTF-8?q?refactor:=20FlowWidget=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 스텝 이름 복원 (탭 영역에 표시되므로 필요) - 데이터 테이블 위 헤더 영역 제거 - 스텝 이름 중복 표시 제거 - '총 N건의 데이터' 표시 제거 - 필터링/선택 건수 표시 제거 - 필터 설정 및 그룹 설정 버튼은 유지 - justify-end로 버튼 오른쪽 정렬 UI 개선: - 데이터 영역 확대로 더 많은 정보 표시 - 중복 정보 제거로 깔끔한 인터페이스 - 필요한 설정 버튼만 간결하게 배치 --- .../components/screen/widgets/FlowWidget.tsx | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index b40e2358..d0a7aabf 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -781,7 +781,16 @@ export function FlowWidget({ onClick={() => handleStepClick(step.id, step.stepName)} > {/* 콘텐츠 */} -
+
+ {/* 스텝 이름 */} +

+ {step.stepName} +

+ {/* 데이터 건수 */} {showStepCount && (
- {/* 헤더 - 자동 높이 */} -
-
-
-

- {steps.find((s) => s.id === selectedStepId)?.stepName} -

-

- 총 {stepData.length}건의 데이터 - {filteredData.length > 0 && ( - (필터링: {filteredData.length}건) - )} - {selectedRows.size > 0 && ( - ({selectedRows.size}건 선택됨) - )} -

-
- - {/* 🆕 필터 설정 버튼 */} - {stepDataColumns.length > 0 && ( -
-
- )} -
+
+ )} {/* 🆕 그룹 표시 배지 */} {groupByColumns.length > 0 && ( From 9f4884f76100adf520dd71c9b4ef898b1d95bdec Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:28:55 +0900 Subject: [PATCH 52/76] =?UTF-8?q?fix:=20FlowWidget=20JSX=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fragment(<>)로 그룹 표시 배지와 검색 필터 영역 감싸기 - stepDataColumns.length > 0 조건 내부로 모든 관련 UI 통합 - 닫는 태그 구조 수정 빌드 오류 해결: - Expected '', got '{' 에러 해결 - JSX 중첩 구조 정상화 --- .../components/screen/widgets/FlowWidget.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index d0a7aabf..dcc4c677 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -859,9 +859,10 @@ export function FlowWidget({
{/* 필터 및 그룹 설정 */} {stepDataColumns.length > 0 && ( -
-
-
- )} - {/* 🆕 그룹 표시 배지 */} - {groupByColumns.length > 0 && ( + {/* 🆕 그룹 표시 배지 */} + {groupByColumns.length > 0 && (
그룹: @@ -970,8 +970,9 @@ export function FlowWidget({ ))}
- )} -
+ )} + + )} {/* 데이터 영역 - 고정 높이 + 스크롤 */} {stepDataLoading ? ( From fd7fc754f48327dfa935c9317016b8b4b610e29b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 14:31:21 +0900 Subject: [PATCH 53/76] =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20-=20=EB=93=B1=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 130 ++++++++++++++++-- .../src/utils/businessNumberValidator.ts | 52 +++++++ .../components/admin/CompanyFormModal.tsx | 130 +++++++++++++++++- frontend/constants/company.ts | 6 + frontend/hooks/useCompanyManagement.ts | 20 +++ frontend/lib/validation/businessNumber.ts | 74 ++++++++++ frontend/types/company.ts | 14 +- 7 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 backend-node/src/utils/businessNumberValidator.ts create mode 100644 frontend/lib/validation/businessNumber.ts diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b1638403..f79aec69 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -8,6 +8,7 @@ import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; +import { validateBusinessNumber } from "../utils/businessNumberValidator"; /** * 관리자 메뉴 목록 조회 @@ -609,9 +610,15 @@ export const getCompanyList = async ( // Raw Query로 회사 목록 조회 const companies = await query( - `SELECT - company_code, + ` SELECT + company_code, company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, status, writer, regdate @@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB( // Raw Query로 회사 목록 조회 const companies = await query( - `SELECT - company_code, + ` SELECT + company_code, company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, writer, regdate, status @@ -2440,6 +2453,25 @@ export const createCompany = async ( [company_name.trim()] ); + // 사업자등록번호 유효성 검증 + const businessNumberValidation = validateBusinessNumber( + req.body.business_registration_number?.trim() || "" + ); + if (!businessNumberValidation.isValid) { + res.status(400).json({ + success: false, + message: businessNumberValidation.message, + errorCode: "INVALID_BUSINESS_NUMBER", + }); + return; + } + + // Raw Query로 사업자등록번호 중복 체크 + const existingBusinessNumber = await queryOne( + `SELECT company_code FROM company_mng WHERE business_registration_number = $1`, + [req.body.business_registration_number?.trim()] + ); + if (existingCompany) { res.status(400).json({ success: false, @@ -2449,6 +2481,15 @@ export const createCompany = async ( return; } + if (existingBusinessNumber) { + res.status(400).json({ + success: false, + message: "이미 등록된 사업자등록번호입니다.", + errorCode: "DUPLICATE_BUSINESS_NUMBER", + }); + return; + } + // PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용) const client = new Client({ connectionString: @@ -2474,11 +2515,17 @@ export const createCompany = async ( const insertQuery = ` INSERT INTO company_mng ( company_code, - company_name, + company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, writer, regdate, status - ) VALUES ($1, $2, $3, $4, $5) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -2488,6 +2535,12 @@ export const createCompany = async ( const insertValues = [ companyCode, company_name.trim(), + req.body.business_registration_number?.trim() || null, + req.body.representative_name?.trim() || null, + req.body.representative_phone?.trim() || null, + req.body.email?.trim() || null, + req.body.website?.trim() || null, + req.body.address?.trim() || null, writer, new Date(), "active", @@ -2552,7 +2605,16 @@ export const updateCompany = async ( ): Promise => { try { const { companyCode } = req.params; - const { company_name, status } = req.body; + const { + company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, + status, + } = req.body; logger.info("회사 정보 수정 요청", { companyCode, @@ -2586,13 +2648,61 @@ export const updateCompany = async ( return; } + // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) + if (business_registration_number && business_registration_number.trim()) { + // 유효성 검증 + const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + if (!businessNumberValidation.isValid) { + res.status(400).json({ + success: false, + message: businessNumberValidation.message, + errorCode: "INVALID_BUSINESS_NUMBER", + }); + return; + } + + // 중복 체크 + const duplicateBusinessNumber = await queryOne( + `SELECT company_code FROM company_mng + WHERE business_registration_number = $1 AND company_code != $2`, + [business_registration_number.trim(), companyCode] + ); + + if (duplicateBusinessNumber) { + res.status(400).json({ + success: false, + message: "이미 등록된 사업자등록번호입니다.", + errorCode: "DUPLICATE_BUSINESS_NUMBER", + }); + return; + } + } + // Raw Query로 회사 정보 수정 const result = await query( `UPDATE company_mng - SET company_name = $1, status = $2 - WHERE company_code = $3 + SET + company_name = $1, + business_registration_number = $2, + representative_name = $3, + representative_phone = $4, + email = $5, + website = $6, + address = $7, + status = $8 + WHERE company_code = $9 RETURNING *`, - [company_name.trim(), status || "active", companyCode] + [ + company_name.trim(), + business_registration_number?.trim() || null, + representative_name?.trim() || null, + representative_phone?.trim() || null, + email?.trim() || null, + website?.trim() || null, + address?.trim() || null, + status || "active", + companyCode, + ] ); if (result.length === 0) { diff --git a/backend-node/src/utils/businessNumberValidator.ts b/backend-node/src/utils/businessNumberValidator.ts new file mode 100644 index 00000000..92385f28 --- /dev/null +++ b/backend-node/src/utils/businessNumberValidator.ts @@ -0,0 +1,52 @@ +/** + * 사업자등록번호 유효성 검사 유틸리티 (백엔드) + */ + +/** + * 사업자등록번호 포맷 검증 + */ +export function validateBusinessNumberFormat(value: string): boolean { + if (!value || value.trim() === "") { + return false; + } + + // 하이픈 제거 + const cleaned = value.replace(/-/g, ""); + + // 숫자 10자리인지 확인 + if (!/^\d{10}$/.test(cleaned)) { + return false; + } + + return true; +} + +/** + * 사업자등록번호 종합 검증 (포맷만 검사) + * 실제 국세청 검증은 API 호출로 처리하는 것을 권장 + */ +export function validateBusinessNumber(value: string): { + isValid: boolean; + message: string; +} { + if (!value || value.trim() === "") { + return { + isValid: false, + message: "사업자등록번호를 입력해주세요.", + }; + } + + if (!validateBusinessNumberFormat(value)) { + return { + isValid: false, + message: "사업자등록번호는 10자리 숫자여야 합니다.", + }; + } + + // 포맷만 검증하고 통과 + return { + isValid: true, + message: "", + }; +} + diff --git a/frontend/components/admin/CompanyFormModal.tsx b/frontend/components/admin/CompanyFormModal.tsx index 7b51ac6a..dd87140e 100644 --- a/frontend/components/admin/CompanyFormModal.tsx +++ b/frontend/components/admin/CompanyFormModal.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber"; interface CompanyFormModalProps { modalState: CompanyModalState; @@ -29,6 +30,7 @@ export function CompanyFormModal({ onClearError, }: CompanyFormModalProps) { const [isSaving, setIsSaving] = useState(false); + const [businessNumberError, setBusinessNumberError] = useState(""); // 모달이 열려있지 않으면 렌더링하지 않음 if (!modalState.isOpen) return null; @@ -36,15 +38,43 @@ export function CompanyFormModal({ const { mode, formData, selectedCompany } = modalState; const isEditMode = mode === "edit"; + // 사업자등록번호 변경 처리 + const handleBusinessNumberChange = (value: string) => { + // 자동 포맷팅 + const formatted = formatBusinessNumber(value); + onFormChange("business_registration_number", formatted); + + // 유효성 검사 (10자리가 다 입력되었을 때만) + const cleaned = formatted.replace(/-/g, ""); + if (cleaned.length === 10) { + const validation = validateBusinessNumber(formatted); + setBusinessNumberError(validation.isValid ? "" : validation.message); + } else if (cleaned.length < 10 && businessNumberError) { + // 10자리 미만이면 에러 초기화 + setBusinessNumberError(""); + } + }; + // 저장 처리 const handleSave = async () => { - // 입력값 검증 + // 입력값 검증 (필수 필드) if (!formData.company_name.trim()) { return; } + if (!formData.business_registration_number.trim()) { + return; + } + + // 사업자등록번호 최종 검증 + const validation = validateBusinessNumber(formData.business_registration_number); + if (!validation.isValid) { + setBusinessNumberError(validation.message); + return; + } setIsSaving(true); onClearError(); + setBusinessNumberError(""); try { const success = await onSave(); @@ -81,7 +111,7 @@ export function CompanyFormModal({
- {/* 회사명 입력 */} + {/* 회사명 입력 (필수) */}
+ {/* 사업자등록번호 입력 (필수) */} +
+ + handleBusinessNumberChange(e.target.value)} + placeholder="000-00-00000" + disabled={isLoading || isSaving} + maxLength={12} + className={businessNumberError ? "border-destructive" : ""} + /> + {businessNumberError ? ( +

{businessNumberError}

+ ) : ( +

10자리 숫자 (자동 하이픈 추가)

+ )} +
+ + {/* 대표자명 입력 */} +
+ + onFormChange("representative_name", e.target.value)} + placeholder="대표자명을 입력하세요" + disabled={isLoading || isSaving} + /> +
+ + {/* 대표 연락처 입력 */} +
+ + onFormChange("representative_phone", e.target.value)} + placeholder="010-0000-0000" + disabled={isLoading || isSaving} + type="tel" + /> +
+ + {/* 이메일 입력 */} +
+ + onFormChange("email", e.target.value)} + placeholder="company@example.com" + disabled={isLoading || isSaving} + type="email" + /> +
+ + {/* 웹사이트 입력 */} +
+ + onFormChange("website", e.target.value)} + placeholder="https://example.com" + disabled={isLoading || isSaving} + type="url" + /> +
+ + {/* 회사 주소 입력 */} +
+ + onFormChange("address", e.target.value)} + placeholder="서울특별시 강남구..." + disabled={isLoading || isSaving} + /> +
+ {/* 에러 메시지 */} {error && ( -
-

{error}

+
+

{error}

)} @@ -129,7 +243,13 @@ export function CompanyFormModal({
)} - {/* 🆕 검색 필터 입력 영역 */} - {searchFilterColumns.size > 0 && ( -
-
- {Object.keys(searchValues).length > 0 && ( - - )} -
- -
- {Array.from(searchFilterColumns).map((col) => ( -
- + {/* 🆕 검색 필터 입력 영역 */} + {searchFilterColumns.size > 0 && ( +
+
+ {Array.from(searchFilterColumns).map((col) => ( setSearchValues((prev) => ({ @@ -964,12 +951,17 @@ export function FlowWidget({ })) } placeholder={`${columnLabels[col] || col} 검색...`} - className="h-8 text-xs" + className="h-8 text-xs w-40" /> -
- ))} + ))} + {Object.keys(searchValues).length > 0 && ( + + )} +
-
)} )} From d9681bb64de4d9b190ed558c493511a2720675d1 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:32:51 +0900 Subject: [PATCH 55/76] =?UTF-8?q?refactor:=20FlowWidget=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EC=A4=84?= =?UTF-8?q?=EC=97=90=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 검색 필터 입력 필드를 필터 설정/그룹 설정 버튼과 동일한 Y좌표에 배치 - 한 줄 레이아웃: [검색입력들...] [초기화] ... [필터설정] [그룹설정] - ml-auto로 설정 버튼들 오른쪽 정렬 - 검색 필드는 왼쪽부터, 설정 버튼은 오른쪽에 배치 - 중복된 검색 필터 입력 영역 제거 UI 개선: - 모든 컨트롤이 하나의 수평선상에 위치 - 공간 효율성 극대화 - 사용자가 요청한 레이아웃 정확히 구현 --- .../components/screen/widgets/FlowWidget.tsx | 139 +++++++++--------- 1 file changed, 70 insertions(+), 69 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index d5835ef3..fd24ea19 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -861,47 +861,76 @@ export function FlowWidget({ {stepDataColumns.length > 0 && ( <>
-
- - +
+ {/* 검색 필터 입력 영역 */} + {searchFilterColumns.size > 0 && ( + <> + {Array.from(searchFilterColumns).map((col) => ( + + setSearchValues((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + placeholder={`${columnLabels[col] || col} 검색...`} + className="h-8 text-xs w-40" + /> + ))} + {Object.keys(searchValues).length > 0 && ( + + )} + + )} + + {/* 필터/그룹 설정 버튼 */} +
+ + +
@@ -935,34 +964,6 @@ export function FlowWidget({
)} - - {/* 🆕 검색 필터 입력 영역 */} - {searchFilterColumns.size > 0 && ( -
-
- {Array.from(searchFilterColumns).map((col) => ( - - setSearchValues((prev) => ({ - ...prev, - [col]: e.target.value, - })) - } - placeholder={`${columnLabels[col] || col} 검색...`} - className="h-8 text-xs w-40" - /> - ))} - {Object.keys(searchValues).length > 0 && ( - - )} -
-
- )} )} From e732ed2891f7d4c9ef0bb39722222e5658dd71a8 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:36:15 +0900 Subject: [PATCH 56/76] =?UTF-8?q?fix:=20TableListComponent=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=80=ED=99=98=20=EC=A1=B0=EA=B1=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - webType과 codeCategory가 있기만 하면 무조건 코드 변환 시도 - 코드 타입이 아닌 컬럼도 코드 카테고리 값으로 변환되는 오류 해결: - webType === 'code'일 때만 코드 변환 수행 - 다른 webType(text, number 등)은 코드 변환 건너뛰기 변경: - meta?.webType && meta?.codeCategory → meta?.webType === 'code' && meta?.codeCategory --- .../lib/registry/components/table-list/TableListComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 365c1bc5..8931b57c 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -675,7 +675,7 @@ export const TableListComponent: React.FC = ({ } const meta = columnMeta[column.columnName]; - if (meta?.webType && meta?.codeCategory) { + if (meta?.webType === "code" && meta?.codeCategory) { const convertedValue = optimizedConvertCode(value, meta.codeCategory); if (convertedValue !== value) return convertedValue; } From 6a329506a84854228a663dd5e4d38a7c47823e68 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:38:27 +0900 Subject: [PATCH 57/76] =?UTF-8?q?fix:=20TableListComponent=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - inputType이 'code'인 컬럼에서 코드 변환이 실행되어 - 실제 저장된 값 대신 코드 카테고리 값이 표시됨 - 사용자가 원하는 것은 원본 값 그대로 표시 해결: - 코드 변환 로직 완전 제거 - inputType에 관계없이 원본 값 그대로 표시 - 숫자/날짜 등 기본 포맷팅만 유지 변경: - optimizedConvertCode 호출 제거 - inputType === 'code' 조건 제거 - 원본 데이터 표시로 단순화 --- .../registry/components/table-list/TableListComponent.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 8931b57c..d3ae5046 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -675,13 +675,11 @@ export const TableListComponent: React.FC = ({ } const meta = columnMeta[column.columnName]; - if (meta?.webType === "code" && meta?.codeCategory) { - const convertedValue = optimizedConvertCode(value, meta.codeCategory); - if (convertedValue !== value) return convertedValue; - } - + // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; + + // 숫자 타입 포맷팅 if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; From a3f945f5df483551ecca0e5cb54b7c3662c379a2 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:39:43 +0900 Subject: [PATCH 58/76] =?UTF-8?q?fix:=20TableListComponent=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=9E=AC?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요구사항: - DB에 저장된 코드 값(예: '001') → 코드명(예: '활성')으로 표시 구현: - inputType === 'code'이고 codeCategory가 있을 때 변환 수행 - optimizedConvertCode를 통해 코드 값 → 코드명 변환 - 변환 성공 시 코드명 반환 - 변환 실패 시 원본 코드 값 반환 - try-catch로 에러 핸들링 추가 - 디버깅을 위한 에러 로그 추가 변경: - 코드 변환 로직 복원 - 에러 처리 강화 - 변환 실패 시 원본 값 표시로 안전장치 --- .../components/table-list/TableListComponent.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index d3ae5046..5b2659ee 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -679,6 +679,21 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; + // 코드 타입: 코드 값 → 코드명 변환 + if (inputType === "code" && meta?.codeCategory && value) { + try { + const convertedValue = optimizedConvertCode(value, meta.codeCategory); + // 변환에 성공했으면 변환된 코드명 반환 + if (convertedValue && convertedValue !== value) { + return convertedValue; + } + } catch (error) { + console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error); + } + // 변환 실패 시 원본 코드 값 반환 + return String(value); + } + // 숫자 타입 포맷팅 if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { From 9a3d65d8d030c325633a95b3cb57c6cc3be26999 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 3 Nov 2025 14:41:12 +0900 Subject: [PATCH 59/76] =?UTF-8?q?fix:=20TableListComponent=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=80=ED=99=98=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=88=9C=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - optimizedConvertCode(value, meta.codeCategory) ❌ - 함수 정의: optimizedConvertCode(categoryCode, codeValue) - 파라미터 순서가 반대로 전달되어 카테고리 값이 표시됨 해결: - optimizedConvertCode(meta.codeCategory, value) ✅ - 올바른 순서: (카테고리, 코드값) - 이제 코드명이 정상적으로 표시됨 변경: - 파라미터 순서 수정 - 주석 추가로 재발 방지 --- .../lib/registry/components/table-list/TableListComponent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 5b2659ee..74a53561 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -682,7 +682,8 @@ export const TableListComponent: React.FC = ({ // 코드 타입: 코드 값 → 코드명 변환 if (inputType === "code" && meta?.codeCategory && value) { try { - const convertedValue = optimizedConvertCode(value, meta.codeCategory); + // optimizedConvertCode(categoryCode, codeValue) 순서 주의! + const convertedValue = optimizedConvertCode(meta.codeCategory, value); // 변환에 성공했으면 변환된 코드명 반환 if (convertedValue && convertedValue !== value) { return convertedValue; From 94e5a5de0bec4ac47acb4e5362215b24c854e85d Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 3 Nov 2025 16:26:32 +0900 Subject: [PATCH 60/76] =?UTF-8?q?=ED=9A=8C=EC=82=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5,=20=EC=9E=91=EC=84=B1=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 14 +++++++++ .../src/services/ddlExecutionService.ts | 2 +- .../screen/InteractiveScreenViewer.tsx | 27 ++++++++++++----- .../screen/InteractiveScreenViewerDynamic.tsx | 3 ++ frontend/components/screen/SaveModal.tsx | 30 ++++++++++++++----- .../table-list/TableListComponent.tsx | 11 +++---- frontend/lib/utils/buttonActions.ts | 24 +++++++++++---- 7 files changed, 86 insertions(+), 25 deletions(-) diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 47ee4e94..9b8ef6fc 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -12,6 +12,14 @@ export const saveFormData = async ( const { companyCode, userId } = req.user as any; const { screenId, tableName, data } = req.body; + // 🔍 디버깅: 사용자 정보 확인 + console.log("🔍 [saveFormData] 사용자 정보:", { + userId, + companyCode, + reqUser: req.user, + dataWriter: data.writer, + }); + // 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크) if (screenId === undefined || screenId === null || !tableName || !data) { return res.status(400).json({ @@ -25,9 +33,12 @@ export const saveFormData = async ( ...data, created_by: userId, updated_by: userId, + writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 screen_id: screenId, }; + console.log("✅ [saveFormData] 최종 writer 값:", formDataWithMeta.writer); + // company_code는 사용자가 명시적으로 입력한 경우에만 추가 if (data.company_code !== undefined) { formDataWithMeta.company_code = data.company_code; @@ -86,6 +97,7 @@ export const saveFormDataEnhanced = async ( ...data, created_by: userId, updated_by: userId, + writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 screen_id: screenId, }; @@ -134,6 +146,7 @@ export const updateFormData = async ( const formDataWithMeta = { ...data, updated_by: userId, + writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 updated_at: new Date(), }; @@ -186,6 +199,7 @@ export const updateFormDataPartial = async ( const newDataWithMeta = { ...newData, updated_by: userId, + writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정 }; const result = await dynamicFormService.updateFormDataPartial( diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts index 37659bcf..2ed01231 100644 --- a/backend-node/src/services/ddlExecutionService.ts +++ b/backend-node/src/services/ddlExecutionService.ts @@ -363,7 +363,7 @@ export class DDLExecutionService { "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, "created_date" timestamp DEFAULT now(), "updated_date" timestamp DEFAULT now(), - "writer" varchar(500), + "writer" varchar(500) DEFAULT NULL, "company_code" varchar(500)`; // 최종 CREATE TABLE 쿼리 diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8a114aa4..572aab37 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1221,6 +1221,12 @@ export const InteractiveScreenViewer: React.FC = ( const handleSaveAction = async () => { // console.log("💾 저장 시작"); + // ✅ 사용자 정보가 로드되지 않았으면 저장 불가 + if (!user?.userId) { + alert("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요."); + return; + } + // 개선된 검증 시스템이 활성화된 경우 if (enhancedValidation) { // console.log("🔍 개선된 검증 시스템 사용"); @@ -1357,19 +1363,26 @@ export const InteractiveScreenViewer: React.FC = ( allComponents.find(c => c.columnName)?.tableName || "dynamic_form_data"; // 기본값 - // 🆕 자동으로 작성자 정보 추가 - const writerValue = user?.userId || userName || "unknown"; + // 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음) + const writerValue = user.userId; + const companyCodeValue = user.companyCode || ""; + console.log("👤 현재 사용자 정보:", { - userId: user?.userId, + userId: user.userId, userName: userName, - writerValue: writerValue, + companyCode: user.companyCode, // ✅ 회사 코드 + formDataWriter: mappedData.writer, // ✅ 폼에서 입력한 writer 값 + formDataCompanyCode: mappedData.company_code, // ✅ 폼에서 입력한 company_code 값 + defaultWriterValue: writerValue, + companyCodeValue, // ✅ 최종 회사 코드 값 }); const dataWithUserInfo = { ...mappedData, - writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼 - created_by: writerValue, - updated_by: writerValue, + writer: mappedData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId + created_by: writerValue, // created_by는 항상 로그인한 사람 + updated_by: writerValue, // updated_by는 항상 로그인한 사람 + company_code: mappedData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; const saveData: DynamicFormData = { diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e85aab58..8df068eb 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -190,6 +190,9 @@ export const InteractiveScreenViewerDynamic: React.FC { console.log("🔍 테이블에서 선택된 행 데이터:", selectedData); diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 271a83d9..dd402933 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -105,6 +105,12 @@ export const SaveModal: React.FC = ({ const handleSave = async () => { if (!screenData || !screenId) return; + // ✅ 사용자 정보가 로드되지 않았으면 저장 불가 + if (!user?.userId) { + toast.error("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요."); + return; + } + try { setIsSaving(true); @@ -129,19 +135,26 @@ export const SaveModal: React.FC = ({ // 저장할 데이터 준비 const dataToSave = initialData ? changedData : formData; - // 🆕 자동으로 작성자 정보 추가 - const writerValue = user?.userId || userName || "unknown"; + // 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음) + const writerValue = user.userId; + const companyCodeValue = user.companyCode || ""; + console.log("👤 현재 사용자 정보:", { - userId: user?.userId, + userId: user.userId, userName: userName, - writerValue: writerValue, + companyCode: user.companyCode, // ✅ 회사 코드 + formDataWriter: dataToSave.writer, // ✅ 폼에서 입력한 writer 값 + formDataCompanyCode: dataToSave.company_code, // ✅ 폼에서 입력한 company_code 값 + defaultWriterValue: writerValue, + companyCodeValue, // ✅ 최종 회사 코드 값 }); const dataWithUserInfo = { ...dataToSave, - writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼 - created_by: writerValue, - updated_by: writerValue, + writer: dataToSave.writer || writerValue, // ✅ 입력값 우선, 없으면 userId + created_by: writerValue, // created_by는 항상 로그인한 사람 + updated_by: writerValue, // updated_by는 항상 로그인한 사람 + company_code: dataToSave.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; // 테이블명 결정 @@ -277,6 +290,9 @@ export const SaveModal: React.FC = ({ }} screenId={screenId} tableName={screenData.tableName} + userId={user?.userId} // ✅ 사용자 ID 전달 + userName={user?.userName} // ✅ 사용자 이름 전달 + companyCode={user?.companyCode} // ✅ 회사 코드 전달 formData={formData} originalData={originalData} onFormDataChange={(fieldName, value) => { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 74a53561..465bb5ce 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -284,7 +284,7 @@ export const TableListComponent: React.FC = ({ // 컬럼 라벨 가져오기 // ======================================== - const fetchColumnLabels = async () => { + const fetchColumnLabels = useCallback(async () => { if (!tableConfig.selectedTable) return; try { @@ -339,13 +339,13 @@ export const TableListComponent: React.FC = ({ } catch (error) { console.error("컬럼 라벨 가져오기 실패:", error); } - }; + }, [tableConfig.selectedTable]); // ======================================== // 테이블 라벨 가져오기 // ======================================== - const fetchTableLabel = async () => { + const fetchTableLabel = useCallback(async () => { if (!tableConfig.selectedTable) return; try { @@ -374,7 +374,7 @@ export const TableListComponent: React.FC = ({ } catch (error) { console.error("테이블 라벨 가져오기 실패:", error); } - }; + }, [tableConfig.selectedTable]); // ======================================== // 데이터 가져오기 @@ -930,7 +930,7 @@ export const TableListComponent: React.FC = ({ useEffect(() => { fetchColumnLabels(); fetchTableLabel(); - }, [tableConfig.selectedTable]); + }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); useEffect(() => { if (!isDesignMode && tableConfig.selectedTable) { @@ -945,6 +945,7 @@ export const TableListComponent: React.FC = ({ searchTerm, refreshKey, isDesignMode, + fetchTableDataDebounced, ]); useEffect(() => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 8b805d93..eb5046e3 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -203,15 +203,29 @@ export class ButtonActionExecutor { // INSERT 처리 // 🆕 자동으로 작성자 정보 추가 - const writerValue = context.userId || context.userName || "unknown"; + if (!context.userId) { + throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요."); + } + + const writerValue = context.userId; const companyCodeValue = context.companyCode || ""; + console.log("👤 [buttonActions] 사용자 정보:", { + userId: context.userId, + userName: context.userName, + companyCode: context.companyCode, // ✅ 회사 코드 + formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 + formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 + defaultWriterValue: writerValue, + companyCodeValue, // ✅ 최종 회사 코드 값 + }); + const dataWithUserInfo = { ...formData, - writer: writerValue, - created_by: writerValue, - updated_by: writerValue, - company_code: companyCodeValue, + writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId + created_by: writerValue, // created_by는 항상 로그인한 사람 + updated_by: writerValue, // updated_by는 항상 로그인한 사람 + company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; saveResult = await DynamicFormApi.saveFormData({ From 257912ea928c4a7e32199a54bd7cb2dee90ccdc1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 16:31:03 +0900 Subject: [PATCH 61/76] =?UTF-8?q?=EB=B6=80=EC=84=9C=20read=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/departmentController.ts | 458 ++++++++++++++++++ backend-node/src/routes/departmentRoutes.ts | 43 ++ .../[companyCode]/departments/page.tsx | 12 + frontend/components/admin/CompanyTable.tsx | 96 ++-- .../admin/department/DepartmentManagement.tsx | 72 +++ .../admin/department/DepartmentMembers.tsx | 247 ++++++++++ .../admin/department/DepartmentStructure.tsx | 275 +++++++++++ frontend/lib/api/department.ts | 131 +++++ frontend/types/department.ts | 69 +++ 10 files changed, 1369 insertions(+), 36 deletions(-) create mode 100644 backend-node/src/controllers/departmentController.ts create mode 100644 backend-node/src/routes/departmentRoutes.ts create mode 100644 frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx create mode 100644 frontend/components/admin/department/DepartmentManagement.tsx create mode 100644 frontend/components/admin/department/DepartmentMembers.tsx create mode 100644 frontend/components/admin/department/DepartmentStructure.tsx create mode 100644 frontend/lib/api/department.ts create mode 100644 frontend/types/department.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b75e6685..9e8c9da5 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -64,6 +64,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 +import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -222,6 +223,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 +app.use("/api/departments", departmentRoutes); // 부서 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts new file mode 100644 index 00000000..34de0800 --- /dev/null +++ b/backend-node/src/controllers/departmentController.ts @@ -0,0 +1,458 @@ +import { Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { query, queryOne } from "../database/db"; + +/** + * 부서 목록 조회 (회사별) + */ +export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const userCompanyCode = req.user?.companyCode; + + logger.info("부서 목록 조회", { companyCode, userCompanyCode }); + + // 최고 관리자가 아니면 자신의 회사만 조회 가능 + if (userCompanyCode !== "*" && userCompanyCode !== companyCode) { + res.status(403).json({ + success: false, + message: "해당 회사의 부서를 조회할 권한이 없습니다.", + }); + return; + } + + // 부서 목록 조회 (부서원 수 포함) + const departments = await query(` + SELECT + d.dept_code, + d.dept_name, + d.company_code, + d.parent_dept_code, + COUNT(DISTINCT ud.user_id) as member_count + FROM dept_info d + LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code + WHERE d.company_code = $1 + GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code + ORDER BY d.dept_name + `, [companyCode]); + + // 응답 형식 변환 + const formattedDepartments = departments.map((dept) => ({ + dept_code: dept.dept_code, + dept_name: dept.dept_name, + company_code: dept.company_code, + parent_dept_code: dept.parent_dept_code, + memberCount: parseInt(dept.member_count || "0"), + })); + + res.status(200).json({ + success: true, + data: formattedDepartments, + }); + } catch (error) { + logger.error("부서 목록 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 상세 조회 + */ +export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + const department = await queryOne(` + SELECT + dept_code, + dept_name, + company_code, + parent_dept_code + FROM dept_info + WHERE dept_code = $1 + `, [deptCode]); + + if (!department) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + res.status(200).json({ + success: true, + data: department, + }); + } catch (error) { + logger.error("부서 상세 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 생성 + */ +export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { dept_name, parent_dept_code } = req.body; + + if (!dept_name || !dept_name.trim()) { + res.status(400).json({ + success: false, + message: "부서명을 입력해주세요.", + }); + return; + } + + // 부서 코드 생성 (DEPT_숫자) + const codeResult = await queryOne(` + SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number + FROM dept_info + WHERE company_code = $1 AND dept_code LIKE 'DEPT_%' + `, [companyCode]); + + const nextNumber = codeResult?.next_number || 1; + const deptCode = `DEPT_${nextNumber}`; + + // 부서 생성 + const result = await query(` + INSERT INTO dept_info ( + dept_code, + dept_name, + company_code, + parent_dept_code + ) VALUES ($1, $2, $3, $4) + RETURNING * + `, [ + deptCode, + dept_name.trim(), + companyCode, + parent_dept_code || null, + ]); + + logger.info("부서 생성 성공", { deptCode, dept_name }); + + res.status(201).json({ + success: true, + message: "부서가 생성되었습니다.", + data: result[0], + }); + } catch (error) { + logger.error("부서 생성 실패", error); + res.status(500).json({ + success: false, + message: "부서 생성 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 수정 + */ +export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + const { dept_name, parent_dept_code } = req.body; + + if (!dept_name || !dept_name.trim()) { + res.status(400).json({ + success: false, + message: "부서명을 입력해주세요.", + }); + return; + } + + const result = await query(` + UPDATE dept_info + SET + dept_name = $1, + parent_dept_code = $2 + WHERE dept_code = $3 + RETURNING * + `, [dept_name.trim(), parent_dept_code || null, deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서 수정 성공", { deptCode }); + + res.status(200).json({ + success: true, + message: "부서가 수정되었습니다.", + data: result[0], + }); + } catch (error) { + logger.error("부서 수정 실패", error); + res.status(500).json({ + success: false, + message: "부서 수정 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 삭제 + */ +export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + // 하위 부서 확인 + const hasChildren = await queryOne(` + SELECT COUNT(*) as count + FROM dept_info + WHERE parent_dept_code = $1 + `, [deptCode]); + + if (parseInt(hasChildren?.count || "0") > 0) { + res.status(400).json({ + success: false, + message: "하위 부서가 있는 부서는 삭제할 수 없습니다.", + }); + return; + } + + // 부서원 확인 + const hasMembers = await queryOne(` + SELECT COUNT(*) as count + FROM user_dept + WHERE dept_code = $1 + `, [deptCode]); + + if (parseInt(hasMembers?.count || "0") > 0) { + res.status(400).json({ + success: false, + message: "부서원이 있는 부서는 삭제할 수 없습니다.", + }); + return; + } + + // 부서 삭제 + const result = await query(` + DELETE FROM dept_info + WHERE dept_code = $1 + RETURNING dept_code, dept_name + `, [deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서 삭제 성공", { deptCode }); + + res.status(200).json({ + success: true, + message: "부서가 삭제되었습니다.", + }); + } catch (error) { + logger.error("부서 삭제 실패", error); + res.status(500).json({ + success: false, + message: "부서 삭제 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 목록 조회 + */ +export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + const members = await query(` + SELECT + u.user_id, + u.user_name, + u.email, + u.tel as phone, + u.cell_phone, + u.position_name, + ud.dept_code, + d.dept_name, + ud.is_primary + FROM user_dept ud + JOIN user_info u ON ud.user_id = u.user_id + JOIN dept_info d ON ud.dept_code = d.dept_code + WHERE ud.dept_code = $1 + ORDER BY ud.is_primary DESC, u.user_name + `, [deptCode]); + + res.status(200).json({ + success: true, + data: members, + }); + } catch (error) { + logger.error("부서원 목록 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서원 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 추가 + */ +export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + const { user_id } = req.body; + + if (!user_id) { + res.status(400).json({ + success: false, + message: "사용자 ID를 입력해주세요.", + }); + return; + } + + // 사용자 존재 확인 + const user = await queryOne(` + SELECT user_id, user_name + FROM user_info + WHERE user_id = $1 + `, [user_id]); + + if (!user) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + // 이미 부서원인지 확인 + const existing = await queryOne(` + SELECT * + FROM user_dept + WHERE user_id = $1 AND dept_code = $2 + `, [user_id, deptCode]); + + if (existing) { + res.status(400).json({ + success: false, + message: "이미 해당 부서의 부서원입니다.", + }); + return; + } + + // 주 부서가 있는지 확인 + const hasPrimary = await queryOne(` + SELECT * + FROM user_dept + WHERE user_id = $1 AND is_primary = true + `, [user_id]); + + // 부서원 추가 + await query(` + INSERT INTO user_dept (user_id, dept_code, is_primary, created_at) + VALUES ($1, $2, $3, NOW()) + `, [user_id, deptCode, !hasPrimary]); + + logger.info("부서원 추가 성공", { user_id, deptCode }); + + res.status(201).json({ + success: true, + message: "부서원이 추가되었습니다.", + }); + } catch (error) { + logger.error("부서원 추가 실패", error); + res.status(500).json({ + success: false, + message: "부서원 추가 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 제거 + */ +export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode, userId } = req.params; + + const result = await query(` + DELETE FROM user_dept + WHERE user_id = $1 AND dept_code = $2 + RETURNING * + `, [userId, deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "해당 부서원을 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서원 제거 성공", { userId, deptCode }); + + res.status(200).json({ + success: true, + message: "부서원이 제거되었습니다.", + }); + } catch (error) { + logger.error("부서원 제거 실패", error); + res.status(500).json({ + success: false, + message: "부서원 제거 중 오류가 발생했습니다.", + }); + } +} + +/** + * 주 부서 설정 + */ +export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode, userId } = req.params; + + // 다른 부서의 주 부서 해제 + await query(` + UPDATE user_dept + SET is_primary = false + WHERE user_id = $1 + `, [userId]); + + // 해당 부서를 주 부서로 설정 + await query(` + UPDATE user_dept + SET is_primary = true + WHERE user_id = $1 AND dept_code = $2 + `, [userId, deptCode]); + + logger.info("주 부서 설정 성공", { userId, deptCode }); + + res.status(200).json({ + success: true, + message: "주 부서가 설정되었습니다.", + }); + } catch (error) { + logger.error("주 부서 설정 실패", error); + res.status(500).json({ + success: false, + message: "주 부서 설정 중 오류가 발생했습니다.", + }); + } +} + diff --git a/backend-node/src/routes/departmentRoutes.ts b/backend-node/src/routes/departmentRoutes.ts new file mode 100644 index 00000000..2f06dd3c --- /dev/null +++ b/backend-node/src/routes/departmentRoutes.ts @@ -0,0 +1,43 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as departmentController from "../controllers/departmentController"; + +const router = Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 부서 관리 API 라우트 + * 기본 경로: /api/departments + */ + +// 부서 목록 조회 (회사별) +router.get("/companies/:companyCode/departments", departmentController.getDepartments); + +// 부서 상세 조회 +router.get("/:deptCode", departmentController.getDepartment); + +// 부서 생성 +router.post("/companies/:companyCode/departments", departmentController.createDepartment); + +// 부서 수정 +router.put("/:deptCode", departmentController.updateDepartment); + +// 부서 삭제 +router.delete("/:deptCode", departmentController.deleteDepartment); + +// 부서원 목록 조회 +router.get("/:deptCode/members", departmentController.getDepartmentMembers); + +// 부서원 추가 +router.post("/:deptCode/members", departmentController.addDepartmentMember); + +// 부서원 제거 +router.delete("/:deptCode/members/:userId", departmentController.removeDepartmentMember); + +// 주 부서 설정 +router.put("/:deptCode/members/:userId/primary", departmentController.setPrimaryDepartment); + +export default router; + diff --git a/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx new file mode 100644 index 00000000..7854e6ee --- /dev/null +++ b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement"; + +export default function DepartmentManagementPage() { + const params = useParams(); + const companyCode = params.companyCode as string; + + return ; +} + diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index 86ccdee3..78b9ca3e 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -1,8 +1,9 @@ -import { Edit, Trash2, HardDrive, FileText } from "lucide-react"; +import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react"; import { Company } from "@/types/company"; import { COMPANY_TABLE_COLUMNS } from "@/constants/company"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useRouter } from "next/navigation"; interface CompanyTableProps { companies: Company[]; @@ -17,11 +18,18 @@ interface CompanyTableProps { * 모바일/태블릿: 카드 뷰 */ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) { + const router = useRouter(); + + // 부서 관리 페이지로 이동 + const handleManageDepartments = (company: Company) => { + router.push(`/admin/company/${company.company_code}/departments`); + }; + // 디스크 사용량 포맷팅 함수 const formatDiskUsage = (company: Company) => { if (!company.diskUsage) { return ( -
+
정보 없음
@@ -33,11 +41,11 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company return (
- + {fileCount}개 파일
- + {totalSizeMB.toFixed(1)} MB
@@ -49,7 +57,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company return ( <> {/* 데스크톱 테이블 스켈레톤 */} -
+
@@ -66,21 +74,21 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {Array.from({ length: 10 }).map((_, index) => ( -
+
-
+
-
+
-
+
-
-
+
+
@@ -92,18 +100,18 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {/* 모바일/태블릿 카드 스켈레톤 */}
{Array.from({ length: 6 }).map((_, index) => ( -
+
-
-
+
+
{Array.from({ length: 3 }).map((_, i) => (
-
-
+
+
))}
@@ -117,9 +125,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company // 데이터가 없을 때 if (companies.length === 0) { return ( -
+
-

등록된 회사가 없습니다.

+

등록된 회사가 없습니다.

); @@ -129,28 +137,40 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company return ( <> {/* 데스크톱 테이블 뷰 (lg 이상) */} -
+
- - - {COMPANY_TABLE_COLUMNS.map((column) => ( - - {column.label} - - ))} - 디스크 사용량 - 작업 - - + + + {COMPANY_TABLE_COLUMNS.map((column) => ( + + {column.label} + + ))} + 디스크 사용량 + 작업 + + {companies.map((company) => ( - + {company.company_code} {company.company_name} {company.writer} {formatDiskUsage(company)}
+ + @@ -219,7 +243,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company variant="outline" size="sm" onClick={() => onDelete(company)} - className="h-9 flex-1 gap-2 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive" + className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm" > 삭제 diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx new file mode 100644 index 00000000..989b6084 --- /dev/null +++ b/frontend/components/admin/department/DepartmentManagement.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { DepartmentStructure } from "./DepartmentStructure"; +import { DepartmentMembers } from "./DepartmentMembers"; +import type { Department } from "@/types/department"; + +interface DepartmentManagementProps { + companyCode: string; +} + +/** + * 부서 관리 메인 컴포넌트 + * 좌측: 부서 구조, 우측: 부서 인원 + */ +export function DepartmentManagement({ companyCode }: DepartmentManagementProps) { + const [selectedDepartment, setSelectedDepartment] = useState(null); + const [activeTab, setActiveTab] = useState("structure"); + + return ( +
+ {/* 탭 네비게이션 (모바일용) */} +
+ + + 부서 구조 + 부서 인원 + + + + + + + + + + +
+ + {/* 좌우 레이아웃 (데스크톱) */} +
+ {/* 좌측: 부서 구조 (30%) */} +
+ +
+ + {/* 우측: 부서 인원 (70%) */} +
+ +
+
+
+ ); +} + + diff --git a/frontend/components/admin/department/DepartmentMembers.tsx b/frontend/components/admin/department/DepartmentMembers.tsx new file mode 100644 index 00000000..a1a79f92 --- /dev/null +++ b/frontend/components/admin/department/DepartmentMembers.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Plus, X, Star } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import type { Department, DepartmentMember } from "@/types/department"; +import * as departmentAPI from "@/lib/api/department"; + +interface DepartmentMembersProps { + companyCode: string; + selectedDepartment: Department | null; +} + +/** + * 부서 인원 관리 컴포넌트 + */ +export function DepartmentMembers({ companyCode, selectedDepartment }: DepartmentMembersProps) { + const [members, setMembers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [searchUserId, setSearchUserId] = useState(""); + + // 부서원 목록 로드 + useEffect(() => { + if (selectedDepartment) { + loadMembers(); + } + }, [selectedDepartment]); + + const loadMembers = async () => { + if (!selectedDepartment) return; + + setIsLoading(true); + try { + const response = await departmentAPI.getDepartmentMembers(selectedDepartment.dept_code); + if (response.success && response.data) { + setMembers(response.data); + } else { + console.error("부서원 목록 로드 실패:", response.error); + setMembers([]); + } + } catch (error) { + console.error("부서원 목록 로드 실패:", error); + setMembers([]); + } finally { + setIsLoading(false); + } + }; + + // 부서원 추가 + const handleAddMember = async () => { + if (!searchUserId.trim() || !selectedDepartment) return; + + try { + const response = await departmentAPI.addDepartmentMember( + selectedDepartment.dept_code, + searchUserId + ); + + if (response.success) { + setIsAddModalOpen(false); + setSearchUserId(""); + loadMembers(); + } else { + alert(response.error || "부서원 추가에 실패했습니다."); + } + } catch (error) { + console.error("부서원 추가 실패:", error); + alert("부서원 추가 중 오류가 발생했습니다."); + } + }; + + // 부서원 제거 + const handleRemoveMember = async (userId: string) => { + if (!selectedDepartment) return; + if (!confirm("이 부서에서 사용자를 제외하시겠습니까?")) return; + + try { + const response = await departmentAPI.removeDepartmentMember( + selectedDepartment.dept_code, + userId + ); + + if (response.success) { + loadMembers(); + } else { + alert(response.error || "부서원 제거에 실패했습니다."); + } + } catch (error) { + console.error("부서원 제거 실패:", error); + alert("부서원 제거 중 오류가 발생했습니다."); + } + }; + + // 주 부서 설정 + const handleSetPrimaryDepartment = async (userId: string) => { + if (!selectedDepartment) return; + + try { + const response = await departmentAPI.setPrimaryDepartment( + selectedDepartment.dept_code, + userId + ); + + if (response.success) { + loadMembers(); + } else { + alert(response.error || "주 부서 설정에 실패했습니다."); + } + } catch (error) { + console.error("주 부서 설정 실패:", error); + alert("주 부서 설정 중 오류가 발생했습니다."); + } + }; + + if (!selectedDepartment) { + return ( +
+

좌측에서 부서를 선택하세요

+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

{selectedDepartment.dept_name}

+

부서원 {members.length}명

+
+ +
+ + {/* 부서원 목록 */} +
+ {isLoading ? ( +
로딩 중...
+ ) : members.length === 0 ? ( +
+ 부서원이 없습니다. 부서원을 추가해주세요. +
+ ) : ( +
+ {members.map((member) => ( +
+
+
+ {member.user_name} + ({member.user_id}) + {member.is_primary && ( + + + 주 부서 + + )} +
+
+ {member.position_name && 직책: {member.position_name}} + {member.email && 이메일: {member.email}} + {member.phone && 전화: {member.phone}} + {member.cell_phone && 휴대폰: {member.cell_phone}} +
+
+ +
+ {!member.is_primary && ( + + )} + +
+
+ ))} +
+ )} +
+ + {/* 부서원 추가 모달 */} + + + + 부서원 추가 + + +
+
+ + setSearchUserId(e.target.value)} + placeholder="사용자 ID를 입력하세요" + autoFocus + /> +

+ 겸직이 가능합니다. 한 사용자를 여러 부서에 추가할 수 있습니다. +

+
+
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx new file mode 100644 index 00000000..1e8b83a9 --- /dev/null +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { Department, DepartmentFormData } from "@/types/department"; +import * as departmentAPI from "@/lib/api/department"; + +interface DepartmentStructureProps { + companyCode: string; + selectedDepartment: Department | null; + onSelectDepartment: (department: Department | null) => void; +} + +/** + * 부서 구조 컴포넌트 (트리 형태) + */ +export function DepartmentStructure({ + companyCode, + selectedDepartment, + onSelectDepartment, +}: DepartmentStructureProps) { + const [departments, setDepartments] = useState([]); + const [expandedDepts, setExpandedDepts] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(false); + + // 부서 추가 모달 + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [parentDeptForAdd, setParentDeptForAdd] = useState(null); + const [newDeptName, setNewDeptName] = useState(""); + + // 부서 목록 로드 + useEffect(() => { + loadDepartments(); + }, [companyCode]); + + const loadDepartments = async () => { + setIsLoading(true); + try { + const response = await departmentAPI.getDepartments(companyCode); + if (response.success && response.data) { + setDepartments(response.data); + } else { + console.error("부서 목록 로드 실패:", response.error); + setDepartments([]); + } + } catch (error) { + console.error("부서 목록 로드 실패:", error); + setDepartments([]); + } finally { + setIsLoading(false); + } + }; + + // 부서 트리 구조 생성 + const buildTree = (parentCode: string | null): Department[] => { + return departments + .filter((dept) => dept.parent_dept_code === parentCode) + .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); + }; + + // 부서 추가 핸들러 + const handleAddDepartment = (parentDeptCode: string | null = null) => { + setParentDeptForAdd(parentDeptCode); + setNewDeptName(""); + setIsAddModalOpen(true); + }; + + // 부서 저장 + const handleSaveDepartment = async () => { + if (!newDeptName.trim()) return; + + try { + const response = await departmentAPI.createDepartment(companyCode, { + dept_name: newDeptName, + parent_dept_code: parentDeptForAdd, + }); + + if (response.success) { + setIsAddModalOpen(false); + loadDepartments(); + } else { + alert(response.error || "부서 추가에 실패했습니다."); + } + } catch (error) { + console.error("부서 추가 실패:", error); + alert("부서 추가 중 오류가 발생했습니다."); + } + }; + + // 부서 삭제 + const handleDeleteDepartment = async (deptCode: string) => { + if (!confirm("이 부서를 삭제하시겠습니까?")) return; + + try { + const response = await departmentAPI.deleteDepartment(deptCode); + + if (response.success) { + loadDepartments(); + } else { + alert(response.error || "부서 삭제에 실패했습니다."); + } + } catch (error) { + console.error("부서 삭제 실패:", error); + alert("부서 삭제 중 오류가 발생했습니다."); + } + }; + + // 확장/축소 토글 + const toggleExpand = (deptCode: string) => { + const newExpanded = new Set(expandedDepts); + if (newExpanded.has(deptCode)) { + newExpanded.delete(deptCode); + } else { + newExpanded.add(deptCode); + } + setExpandedDepts(newExpanded); + }; + + // 부서 트리 렌더링 (재귀) + const renderDepartmentTree = (parentCode: string | null, level: number = 0) => { + const children = buildTree(parentCode); + + return children.map((dept) => { + const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code); + const isExpanded = expandedDepts.has(dept.dept_code); + const isSelected = selectedDepartment?.dept_code === dept.dept_code; + + return ( +
+ {/* 부서 항목 */} +
+
onSelectDepartment(dept)} + > + {/* 확장/축소 아이콘 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 부서명 */} + {dept.dept_name} + + {/* 인원수 */} +
+ + {dept.memberCount || 0} +
+
+ + {/* 액션 버튼 */} +
+ + +
+
+ + {/* 하위 부서 (재귀) */} + {hasChildren && isExpanded && renderDepartmentTree(dept.dept_code, level + 1)} +
+ ); + }); + }; + + return ( +
+ {/* 헤더 */} +
+

부서 구조

+ +
+ + {/* 부서 트리 */} +
+ {isLoading ? ( +
로딩 중...
+ ) : departments.length === 0 ? ( +
+ 부서가 없습니다. 최상위 부서를 추가해주세요. +
+ ) : ( + renderDepartmentTree(null) + )} +
+ + {/* 부서 추가 모달 */} + + + + + {parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"} + + + +
+
+ + setNewDeptName(e.target.value)} + placeholder="부서명을 입력하세요" + autoFocus + /> +
+
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts new file mode 100644 index 00000000..a3f13962 --- /dev/null +++ b/frontend/lib/api/department.ts @@ -0,0 +1,131 @@ +/** + * 부서 관리 API 클라이언트 + */ + +import { apiClient } from "./client"; +import { Department, DepartmentMember, DepartmentFormData } from "@/types/department"; + +/** + * 부서 목록 조회 (회사별) + */ +export async function getDepartments(companyCode: string) { + try { + const url = `/departments/companies/${companyCode}/departments`; + const response = await apiClient.get<{ success: boolean; data: Department[] }>(url); + return response.data; + } catch (error: any) { + console.error("부서 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 상세 조회 + */ +export async function getDepartment(deptCode: string) { + try { + const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`); + return response.data; + } catch (error: any) { + console.error("부서 상세 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 생성 + */ +export async function createDepartment(companyCode: string, data: DepartmentFormData) { + try { + const response = await apiClient.post<{ success: boolean; data: Department }>( + `/departments/companies/${companyCode}/departments`, + data, + ); + return response.data; + } catch (error: any) { + console.error("부서 생성 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 수정 + */ +export async function updateDepartment(deptCode: string, data: DepartmentFormData) { + try { + const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data); + return response.data; + } catch (error: any) { + console.error("부서 수정 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 삭제 + */ +export async function deleteDepartment(deptCode: string) { + try { + const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`); + return response.data; + } catch (error: any) { + console.error("부서 삭제 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 목록 조회 + */ +export async function getDepartmentMembers(deptCode: string) { + try { + const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>( + `/departments/${deptCode}/members`, + ); + return response.data; + } catch (error: any) { + console.error("부서원 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 추가 + */ +export async function addDepartmentMember(deptCode: string, userId: string) { + try { + const response = await apiClient.post<{ success: boolean }>(`/departments/${deptCode}/members`, { + user_id: userId, + }); + return response.data; + } catch (error: any) { + console.error("부서원 추가 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 제거 + */ +export async function removeDepartmentMember(deptCode: string, userId: string) { + try { + const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`); + return response.data; + } catch (error: any) { + console.error("부서원 제거 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 주 부서 설정 + */ +export async function setPrimaryDepartment(deptCode: string, userId: string) { + try { + const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`); + return response.data; + } catch (error: any) { + console.error("주 부서 설정 실패:", error); + return { success: false, error: error.message }; + } +} diff --git a/frontend/types/department.ts b/frontend/types/department.ts new file mode 100644 index 00000000..0abc25d0 --- /dev/null +++ b/frontend/types/department.ts @@ -0,0 +1,69 @@ +/** + * 부서 관리 관련 타입 정의 + */ + +// 부서 정보 (dept_info 테이블 기반) +export interface Department { + dept_code: string; // 부서 코드 + dept_name: string; // 부서명 + company_code: string; // 회사 코드 + parent_dept_code?: string | null; // 상위 부서 코드 + sort_order?: number; // 정렬 순서 + created_at?: string; + updated_at?: string; + // UI용 추가 필드 + children?: Department[]; // 하위 부서 목록 + memberCount?: number; // 부서원 수 +} + +// 부서원 정보 +export interface DepartmentMember { + user_id: string; // 사용자 ID + user_name: string; // 사용자명 + dept_code: string; // 부서 코드 + dept_name: string; // 부서명 + is_primary: boolean; // 주 부서 여부 + position_name?: string; // 직책명 + email?: string; // 이메일 + phone?: string; // 전화번호 + cell_phone?: string; // 휴대폰 +} + +// 사용자-부서 매핑 (겸직 지원) +export interface UserDepartmentMapping { + user_id: string; + dept_code: string; + is_primary: boolean; // 주 부서 여부 + created_at?: string; +} + +// 부서 등록/수정 폼 데이터 +export interface DepartmentFormData { + dept_name: string; // 부서명 (필수) + parent_dept_code?: string | null; // 상위 부서 코드 +} + +// 부서 트리 노드 (UI용) +export interface DepartmentTreeNode { + dept_code: string; + dept_name: string; + parent_dept_code?: string | null; + children: DepartmentTreeNode[]; + memberCount: number; + isExpanded: boolean; +} + +// 부서 API 응답 +export interface DepartmentApiResponse { + success: boolean; + message: string; + data?: Department | Department[]; +} + +// 부서원 API 응답 +export interface DepartmentMemberApiResponse { + success: boolean; + message: string; + data?: DepartmentMember | DepartmentMember[]; +} + From 5629cd999f6c78d5c50f6f0826138155bfce88ca Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 16:37:34 +0900 Subject: [PATCH 62/76] =?UTF-8?q?=ED=99=94=EB=A9=B4=EB=B9=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/department/DepartmentManagement.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx index 989b6084..b2916374 100644 --- a/frontend/components/admin/department/DepartmentManagement.tsx +++ b/frontend/components/admin/department/DepartmentManagement.tsx @@ -48,8 +48,8 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {/* 좌우 레이아웃 (데스크톱) */}
- {/* 좌측: 부서 구조 (30%) */} -
+ {/* 좌측: 부서 구조 (20%) */} +
- {/* 우측: 부서 인원 (70%) */} -
+ {/* 우측: 부서 인원 (80%) */} +
Date: Mon, 3 Nov 2025 16:40:45 +0900 Subject: [PATCH 63/76] =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EB=92=A4=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=84=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/department/DepartmentManagement.tsx | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx index b2916374..abad5fe6 100644 --- a/frontend/components/admin/department/DepartmentManagement.tsx +++ b/frontend/components/admin/department/DepartmentManagement.tsx @@ -1,11 +1,14 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ArrowLeft } from "lucide-react"; import { DepartmentStructure } from "./DepartmentStructure"; import { DepartmentMembers } from "./DepartmentMembers"; import type { Department } from "@/types/department"; +import { getCompanyList } from "@/lib/api/company"; interface DepartmentManagementProps { companyCode: string; @@ -16,11 +19,45 @@ interface DepartmentManagementProps { * 좌측: 부서 구조, 우측: 부서 인원 */ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) { + const router = useRouter(); const [selectedDepartment, setSelectedDepartment] = useState(null); const [activeTab, setActiveTab] = useState("structure"); + const [companyName, setCompanyName] = useState(""); + + // 회사 정보 로드 + useEffect(() => { + const loadCompanyInfo = async () => { + const response = await getCompanyList(); + if (response.success && response.data) { + const company = response.data.find((c) => c.company_code === companyCode); + if (company) { + setCompanyName(company.company_name); + } + } + }; + loadCompanyInfo(); + }, [companyCode]); + + const handleBackToList = () => { + router.push("/admin/company"); + }; return (
+ {/* 상단 헤더: 회사 정보 + 뒤로가기 */} +
+
+ +
+
+

{companyName || companyCode}

+

부서 관리

+
+
+
{/* 탭 네비게이션 (모바일용) */}
@@ -38,10 +75,7 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) - +
@@ -59,14 +93,9 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {/* 우측: 부서 인원 (80%) */}
- +
); } - - From 0d6b018ec45ac493f75a0b3cf0f7b09957df188f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 16:59:01 +0900 Subject: [PATCH 64/76] =?UTF-8?q?=EB=B6=80=EC=84=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/departmentController.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index 34de0800..deb729ae 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -114,12 +114,19 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) return; } - // 부서 코드 생성 (DEPT_숫자) + // 회사 이름 조회 + const company = await queryOne(` + SELECT company_name FROM company_mng WHERE company_code = $1 + `, [companyCode]); + + const companyName = company?.company_name || companyCode; + + // 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...) const codeResult = await queryOne(` SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number FROM dept_info - WHERE company_code = $1 AND dept_code LIKE 'DEPT_%' - `, [companyCode]); + WHERE dept_code ~ '^DEPT_[0-9]+$' + `); const nextNumber = codeResult?.next_number || 1; const deptCode = `DEPT_${nextNumber}`; @@ -130,14 +137,19 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) dept_code, dept_name, company_code, - parent_dept_code - ) VALUES ($1, $2, $3, $4) + company_name, + parent_dept_code, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING * `, [ deptCode, dept_name.trim(), companyCode, + companyName, parent_dept_code || null, + 'active', ]); logger.info("부서 생성 성공", { deptCode, dept_name }); From 6b53cb414c9356f569f63d2e65b0aad05e8018d8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 17:28:12 +0900 Subject: [PATCH 65/76] =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=A5=BC=20alert?= =?UTF-8?q?=EC=97=90=EC=84=9C=20modal=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/departmentController.ts | 66 +++++- backend-node/src/routes/departmentRoutes.ts | 3 + .../admin/department/DepartmentManagement.tsx | 20 +- .../admin/department/DepartmentMembers.tsx | 209 +++++++++++++++--- .../admin/department/DepartmentStructure.tsx | 91 +++++++- frontend/lib/api/department.ts | 32 ++- 6 files changed, 380 insertions(+), 41 deletions(-) diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index deb729ae..93b37475 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -114,6 +114,22 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) return; } + // 같은 회사 내 중복 부서명 확인 + const duplicate = await queryOne(` + SELECT dept_code, dept_name + FROM dept_info + WHERE company_code = $1 AND dept_name = $2 + `, [companyCode, dept_name.trim()]); + + if (duplicate) { + res.status(409).json({ + success: false, + message: `"${dept_name}" 부서가 이미 존재합니다.`, + isDuplicate: true, + }); + return; + } + // 회사 이름 조회 const company = await queryOne(` SELECT company_name FROM company_mng WHERE company_code = $1 @@ -322,6 +338,53 @@ export async function getDepartmentMembers(req: AuthenticatedRequest, res: Respo } } +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { search } = req.query; + + if (!search || typeof search !== 'string') { + res.status(400).json({ + success: false, + message: "검색어를 입력해주세요.", + }); + return; + } + + // 사용자 검색 (ID 또는 이름) + const users = await query(` + SELECT + user_id, + user_name, + email, + position_name, + company_code + FROM user_info + WHERE company_code = $1 + AND ( + user_id ILIKE $2 OR + user_name ILIKE $2 + ) + ORDER BY user_name + LIMIT 20 + `, [companyCode, `%${search}%`]); + + res.status(200).json({ + success: true, + data: users, + }); + } catch (error) { + logger.error("사용자 검색 실패", error); + res.status(500).json({ + success: false, + message: "사용자 검색 중 오류가 발생했습니다.", + }); + } +} + /** * 부서원 추가 */ @@ -361,9 +424,10 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon `, [user_id, deptCode]); if (existing) { - res.status(400).json({ + res.status(409).json({ success: false, message: "이미 해당 부서의 부서원입니다.", + isDuplicate: true, }); return; } diff --git a/backend-node/src/routes/departmentRoutes.ts b/backend-node/src/routes/departmentRoutes.ts index 2f06dd3c..52cc309e 100644 --- a/backend-node/src/routes/departmentRoutes.ts +++ b/backend-node/src/routes/departmentRoutes.ts @@ -30,6 +30,9 @@ router.delete("/:deptCode", departmentController.deleteDepartment); // 부서원 목록 조회 router.get("/:deptCode/members", departmentController.getDepartmentMembers); +// 사용자 검색 (부서원 추가용) +router.get("/companies/:companyCode/users/search", departmentController.searchUsers); + // 부서원 추가 router.post("/:deptCode/members", departmentController.addDepartmentMember); diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx index abad5fe6..e82be525 100644 --- a/frontend/components/admin/department/DepartmentManagement.tsx +++ b/frontend/components/admin/department/DepartmentManagement.tsx @@ -23,6 +23,12 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) const [selectedDepartment, setSelectedDepartment] = useState(null); const [activeTab, setActiveTab] = useState("structure"); const [companyName, setCompanyName] = useState(""); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + // 부서원 변경 시 부서 구조 새로고침 + const handleMemberChange = () => { + setRefreshTrigger((prev) => prev + 1); + }; // 회사 정보 로드 useEffect(() => { @@ -71,11 +77,16 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) companyCode={companyCode} selectedDepartment={selectedDepartment} onSelectDepartment={setSelectedDepartment} + refreshTrigger={refreshTrigger} /> - +
@@ -88,12 +99,17 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) companyCode={companyCode} selectedDepartment={selectedDepartment} onSelectDepartment={setSelectedDepartment} + refreshTrigger={refreshTrigger} />
{/* 우측: 부서 인원 (80%) */}
- +
diff --git a/frontend/components/admin/department/DepartmentMembers.tsx b/frontend/components/admin/department/DepartmentMembers.tsx index a1a79f92..6fb398ec 100644 --- a/frontend/components/admin/department/DepartmentMembers.tsx +++ b/frontend/components/admin/department/DepartmentMembers.tsx @@ -19,16 +19,28 @@ import * as departmentAPI from "@/lib/api/department"; interface DepartmentMembersProps { companyCode: string; selectedDepartment: Department | null; + onMemberChange?: () => void; } /** * 부서 인원 관리 컴포넌트 */ -export function DepartmentMembers({ companyCode, selectedDepartment }: DepartmentMembersProps) { +export function DepartmentMembers({ + companyCode, + selectedDepartment, + onMemberChange, +}: DepartmentMembersProps) { const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [searchUserId, setSearchUserId] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [duplicateMessage, setDuplicateMessage] = useState(null); + + // 부서원 삭제 확인 모달 + const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); + const [memberToRemove, setMemberToRemove] = useState<{ userId: string; name: string } | null>(null); // 부서원 목록 로드 useEffect(() => { @@ -57,22 +69,51 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen } }; + // 사용자 검색 + const handleSearch = async () => { + if (!searchQuery.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const response = await departmentAPI.searchUsers(companyCode, searchQuery); + if (response.success && response.data) { + setSearchResults(response.data); + } else { + setSearchResults([]); + } + } catch (error) { + console.error("사용자 검색 실패:", error); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }; + // 부서원 추가 - const handleAddMember = async () => { - if (!searchUserId.trim() || !selectedDepartment) return; + const handleAddMember = async (userId: string) => { + if (!selectedDepartment) return; try { const response = await departmentAPI.addDepartmentMember( selectedDepartment.dept_code, - searchUserId + userId ); if (response.success) { setIsAddModalOpen(false); - setSearchUserId(""); + setSearchQuery(""); + setSearchResults([]); loadMembers(); + onMemberChange?.(); // 부서 구조 새로고침 } else { - alert(response.error || "부서원 추가에 실패했습니다."); + if ((response as any).isDuplicate) { + setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다."); + } else { + alert(response.error || "부서원 추가에 실패했습니다."); + } } } catch (error) { console.error("부서원 추가 실패:", error); @@ -80,19 +121,27 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen } }; - // 부서원 제거 - const handleRemoveMember = async (userId: string) => { - if (!selectedDepartment) return; - if (!confirm("이 부서에서 사용자를 제외하시겠습니까?")) return; + // 부서원 제거 확인 요청 + const handleRemoveMemberRequest = (userId: string, userName: string) => { + setMemberToRemove({ userId, name: userName }); + setRemoveConfirmOpen(true); + }; + + // 부서원 제거 실행 + const handleRemoveMemberConfirm = async () => { + if (!selectedDepartment || !memberToRemove) return; try { const response = await departmentAPI.removeDepartmentMember( selectedDepartment.dept_code, - userId + memberToRemove.userId ); if (response.success) { + setRemoveConfirmOpen(false); + setMemberToRemove(null); loadMembers(); + onMemberChange?.(); // 부서 구조 새로고침 } else { alert(response.error || "부서원 제거에 실패했습니다."); } @@ -195,7 +244,7 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen variant="ghost" size="icon" className="h-8 w-8 text-destructive" - onClick={() => handleRemoveMember(member.user_id)} + onClick={() => handleRemoveMemberRequest(member.user_id, member.user_name)} > @@ -208,35 +257,139 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen {/* 부서원 추가 모달 */} - + - 부서원 추가 + 부서원 추가 -
+
+ {/* 검색 입력 */}
-
+ + {/* 검색 결과 */} + {isSearching ? ( +
검색 중...
+ ) : searchResults.length > 0 ? ( +
+ {searchResults.map((user) => ( +
handleAddMember(user.user_id)} + > +
+
+ {user.user_name} + ({user.user_id}) +
+
+ {user.position_name && {user.position_name}} + {user.email && {user.email}} +
+
+ +
+ ))} +
+ ) : searchQuery && !isSearching ? ( +
+ 검색 결과가 없습니다. +
+ ) : null}
+ + + + +
+ + {/* 중복 알림 모달 */} + setDuplicateMessage(null)}> + + + 중복 알림 + +
+

{duplicateMessage}

+
- + +
+
+ + {/* 부서원 제거 확인 모달 */} + + + + 부서원 제거 확인 + +
+

+ {memberToRemove?.name} 님을 이 부서에서 제외하시겠습니까? +

+

+ 다른 부서에는 영향을 주지 않습니다. +

+
+ + -
diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index 1e8b83a9..49df54bc 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -19,6 +19,7 @@ interface DepartmentStructureProps { companyCode: string; selectedDepartment: Department | null; onSelectDepartment: (department: Department | null) => void; + refreshTrigger?: number; } /** @@ -28,6 +29,7 @@ export function DepartmentStructure({ companyCode, selectedDepartment, onSelectDepartment, + refreshTrigger, }: DepartmentStructureProps) { const [departments, setDepartments] = useState([]); const [expandedDepts, setExpandedDepts] = useState>(new Set()); @@ -37,11 +39,16 @@ export function DepartmentStructure({ const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [parentDeptForAdd, setParentDeptForAdd] = useState(null); const [newDeptName, setNewDeptName] = useState(""); + const [duplicateMessage, setDuplicateMessage] = useState(null); + + // 부서 삭제 확인 모달 + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null); // 부서 목록 로드 useEffect(() => { loadDepartments(); - }, [companyCode]); + }, [companyCode, refreshTrigger]); const loadDepartments = async () => { setIsLoading(true); @@ -87,9 +94,15 @@ export function DepartmentStructure({ if (response.success) { setIsAddModalOpen(false); + setNewDeptName(""); + setParentDeptForAdd(null); loadDepartments(); } else { - alert(response.error || "부서 추가에 실패했습니다."); + if ((response as any).isDuplicate) { + setDuplicateMessage(response.error || "이미 존재하는 부서명입니다."); + } else { + alert(response.error || "부서 추가에 실패했습니다."); + } } } catch (error) { console.error("부서 추가 실패:", error); @@ -97,14 +110,22 @@ export function DepartmentStructure({ } }; - // 부서 삭제 - const handleDeleteDepartment = async (deptCode: string) => { - if (!confirm("이 부서를 삭제하시겠습니까?")) return; + // 부서 삭제 확인 요청 + const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => { + setDeptToDelete({ code: deptCode, name: deptName }); + setDeleteConfirmOpen(true); + }; + + // 부서 삭제 실행 + const handleDeleteDepartmentConfirm = async () => { + if (!deptToDelete) return; try { - const response = await departmentAPI.deleteDepartment(deptCode); + const response = await departmentAPI.deleteDepartment(deptToDelete.code); if (response.success) { + setDeleteConfirmOpen(false); + setDeptToDelete(null); loadDepartments(); } else { alert(response.error || "부서 삭제에 실패했습니다."); @@ -196,7 +217,7 @@ export function DepartmentStructure({ className="h-6 w-6 text-destructive" onClick={(e) => { e.stopPropagation(); - handleDeleteDepartment(dept.dept_code); + handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name); }} > @@ -269,6 +290,62 @@ export function DepartmentStructure({
+ + {/* 중복 알림 모달 */} + setDuplicateMessage(null)}> + + + 중복 알림 + +
+

{duplicateMessage}

+
+ + + +
+
+ + {/* 부서 삭제 확인 모달 */} + + + + 부서 삭제 확인 + +
+

+ {deptToDelete?.name} 부서를 삭제하시겠습니까? +

+

+ 이 작업은 되돌릴 수 없습니다. +

+
+ + + + +
+
); } diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts index a3f13962..2486d11f 100644 --- a/frontend/lib/api/department.ts +++ b/frontend/lib/api/department.ts @@ -44,7 +44,12 @@ export async function createDepartment(companyCode: string, data: DepartmentForm return response.data; } catch (error: any) { console.error("부서 생성 실패:", error); - return { success: false, error: error.message }; + const isDuplicate = error.response?.status === 409; + return { + success: false, + error: error.response?.data?.message || error.message, + isDuplicate, + }; } } @@ -89,18 +94,39 @@ export async function getDepartmentMembers(deptCode: string) { } } +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(companyCode: string, search: string) { + try { + const response = await apiClient.get<{ success: boolean; data: any[] }>( + `/departments/companies/${companyCode}/users/search`, + { params: { search } }, + ); + return response.data; + } catch (error: any) { + console.error("사용자 검색 실패:", error); + return { success: false, error: error.message }; + } +} + /** * 부서원 추가 */ export async function addDepartmentMember(deptCode: string, userId: string) { try { - const response = await apiClient.post<{ success: boolean }>(`/departments/${deptCode}/members`, { + const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, { user_id: userId, }); return response.data; } catch (error: any) { console.error("부서원 추가 실패:", error); - return { success: false, error: error.message }; + const isDuplicate = error.response?.status === 409; + return { + success: false, + error: error.response?.data?.message || error.message, + isDuplicate, + }; } } From c50c8d01df50b959bbe752ffae1a2364908f48af Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 17:42:46 +0900 Subject: [PATCH 66/76] =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=ED=9B=84=EC=97=94?= =?UTF-8?q?=20=EB=B6=80=EC=84=9C=20=EC=84=A0=ED=83=9D=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/departmentController.ts | 28 ++--- .../admin/department/DepartmentMembers.tsx | 59 +++++++++- .../admin/department/DepartmentStructure.tsx | 109 +++++++++++------- 3 files changed, 137 insertions(+), 59 deletions(-) diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index 93b37475..9e3f0b6a 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -250,25 +250,19 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) if (parseInt(hasChildren?.count || "0") > 0) { res.status(400).json({ success: false, - message: "하위 부서가 있는 부서는 삭제할 수 없습니다.", + message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.", }); return; } - // 부서원 확인 - const hasMembers = await queryOne(` - SELECT COUNT(*) as count - FROM user_dept + // 부서원 삭제 (부서 삭제 전에 먼저 삭제) + const deletedMembers = await query(` + DELETE FROM user_dept WHERE dept_code = $1 + RETURNING user_id `, [deptCode]); - if (parseInt(hasMembers?.count || "0") > 0) { - res.status(400).json({ - success: false, - message: "부서원이 있는 부서는 삭제할 수 없습니다.", - }); - return; - } + const memberCount = deletedMembers.length; // 부서 삭제 const result = await query(` @@ -285,11 +279,17 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) return; } - logger.info("부서 삭제 성공", { deptCode }); + logger.info("부서 삭제 성공", { + deptCode, + deptName: result[0].dept_name, + deletedMemberCount: memberCount + }); res.status(200).json({ success: true, - message: "부서가 삭제되었습니다.", + message: memberCount > 0 + ? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)` + : "부서가 삭제되었습니다.", }); } catch (error) { logger.error("부서 삭제 실패", error); diff --git a/frontend/components/admin/department/DepartmentMembers.tsx b/frontend/components/admin/department/DepartmentMembers.tsx index 6fb398ec..7147971b 100644 --- a/frontend/components/admin/department/DepartmentMembers.tsx +++ b/frontend/components/admin/department/DepartmentMembers.tsx @@ -13,6 +13,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; import type { Department, DepartmentMember } from "@/types/department"; import * as departmentAPI from "@/lib/api/department"; @@ -30,6 +31,7 @@ export function DepartmentMembers({ selectedDepartment, onMemberChange, }: DepartmentMembersProps) { + const { toast } = useToast(); const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -108,16 +110,31 @@ export function DepartmentMembers({ setSearchResults([]); loadMembers(); onMemberChange?.(); // 부서 구조 새로고침 + + // 성공 Toast 표시 + toast({ + title: "부서원 추가 완료", + description: "부서원이 추가되었습니다.", + variant: "default", + }); } else { if ((response as any).isDuplicate) { setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다."); } else { - alert(response.error || "부서원 추가에 실패했습니다."); + toast({ + title: "부서원 추가 실패", + description: response.error || "부서원 추가에 실패했습니다.", + variant: "destructive", + }); } } } catch (error) { console.error("부서원 추가 실패:", error); - alert("부서원 추가 중 오류가 발생했습니다."); + toast({ + title: "부서원 추가 실패", + description: "부서원 추가 중 오류가 발생했습니다.", + variant: "destructive", + }); } }; @@ -142,12 +159,27 @@ export function DepartmentMembers({ setMemberToRemove(null); loadMembers(); onMemberChange?.(); // 부서 구조 새로고침 + + // 성공 Toast 표시 + toast({ + title: "부서원 제거 완료", + description: `${memberToRemove.name} 님이 부서에서 제외되었습니다.`, + variant: "default", + }); } else { - alert(response.error || "부서원 제거에 실패했습니다."); + toast({ + title: "부서원 제거 실패", + description: response.error || "부서원 제거에 실패했습니다.", + variant: "destructive", + }); } } catch (error) { console.error("부서원 제거 실패:", error); - alert("부서원 제거 중 오류가 발생했습니다."); + toast({ + title: "부서원 제거 실패", + description: "부서원 제거 중 오류가 발생했습니다.", + variant: "destructive", + }); } }; @@ -163,12 +195,27 @@ export function DepartmentMembers({ if (response.success) { loadMembers(); + + // 성공 Toast 표시 + toast({ + title: "주 부서 설정 완료", + description: "주 부서가 변경되었습니다.", + variant: "default", + }); } else { - alert(response.error || "주 부서 설정에 실패했습니다."); + toast({ + title: "주 부서 설정 실패", + description: response.error || "주 부서 설정에 실패했습니다.", + variant: "destructive", + }); } } catch (error) { console.error("주 부서 설정 실패:", error); - alert("주 부서 설정 중 오류가 발생했습니다."); + toast({ + title: "주 부서 설정 실패", + description: "주 부서 설정 중 오류가 발생했습니다.", + variant: "destructive", + }); } }; diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index 49df54bc..4347d612 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -3,16 +3,11 @@ import { useState, useEffect } from "react"; import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { Department, DepartmentFormData } from "@/types/department"; +import { useToast } from "@/hooks/use-toast"; +import type { Department } from "@/types/department"; import * as departmentAPI from "@/lib/api/department"; interface DepartmentStructureProps { @@ -31,6 +26,7 @@ export function DepartmentStructure({ onSelectDepartment, refreshTrigger, }: DepartmentStructureProps) { + const { toast } = useToast(); const [departments, setDepartments] = useState([]); const [expandedDepts, setExpandedDepts] = useState>(new Set()); const [isLoading, setIsLoading] = useState(false); @@ -40,10 +36,11 @@ export function DepartmentStructure({ const [parentDeptForAdd, setParentDeptForAdd] = useState(null); const [newDeptName, setNewDeptName] = useState(""); const [duplicateMessage, setDuplicateMessage] = useState(null); - + // 부서 삭제 확인 모달 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null); + const [deleteErrorMessage, setDeleteErrorMessage] = useState(null); // 부서 목록 로드 useEffect(() => { @@ -97,16 +94,31 @@ export function DepartmentStructure({ setNewDeptName(""); setParentDeptForAdd(null); loadDepartments(); + + // 성공 Toast 표시 + toast({ + title: "부서 생성 완료", + description: `"${newDeptName}" 부서가 생성되었습니다.`, + variant: "default", + }); } else { if ((response as any).isDuplicate) { setDuplicateMessage(response.error || "이미 존재하는 부서명입니다."); } else { - alert(response.error || "부서 추가에 실패했습니다."); + toast({ + title: "부서 생성 실패", + description: response.error || "부서 추가에 실패했습니다.", + variant: "destructive", + }); } } } catch (error) { console.error("부서 추가 실패:", error); - alert("부서 추가 중 오류가 발생했습니다."); + toast({ + title: "부서 생성 실패", + description: "부서 추가 중 오류가 발생했습니다.", + variant: "destructive", + }); } }; @@ -124,15 +136,32 @@ export function DepartmentStructure({ const response = await departmentAPI.deleteDepartment(deptToDelete.code); if (response.success) { + // 삭제된 부서가 선택되어 있었다면 선택 해제 + if (selectedDepartment?.dept_code === deptToDelete.code) { + onSelectDepartment(null); + } + setDeleteConfirmOpen(false); setDeptToDelete(null); loadDepartments(); + + // 성공 메시지 Toast로 표시 (부서원 수 포함) + toast({ + title: "부서 삭제 완료", + description: response.message || "부서가 삭제되었습니다.", + variant: "default", + }); } else { - alert(response.error || "부서 삭제에 실패했습니다."); + // 삭제 확인 모달을 닫고 에러 모달을 표시 + setDeleteConfirmOpen(false); + setDeptToDelete(null); + setDeleteErrorMessage(response.error || "부서 삭제에 실패했습니다."); } } catch (error) { console.error("부서 삭제 실패:", error); - alert("부서 삭제 중 오류가 발생했습니다."); + setDeleteConfirmOpen(false); + setDeptToDelete(null); + setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다."); } }; @@ -160,15 +189,12 @@ export function DepartmentStructure({
{/* 부서 항목 */}
-
onSelectDepartment(dept)} - > +
onSelectDepartment(dept)}> {/* 확장/축소 아이콘 */} {hasChildren ? ( ) : (
@@ -192,7 +214,7 @@ export function DepartmentStructure({ {dept.dept_name} {/* 인원수 */} -
+
{dept.memberCount || 0}
@@ -214,7 +236,7 @@ export function DepartmentStructure({
{/* 부서 트리 */} -
+
{isLoading ? ( -
로딩 중...
+
로딩 중...
) : departments.length === 0 ? ( -
+
부서가 없습니다. 최상위 부서를 추가해주세요.
) : ( @@ -260,9 +282,7 @@ export function DepartmentStructure({ - - {parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"} - + {parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}
@@ -301,10 +321,7 @@ export function DepartmentStructure({

{duplicateMessage}

- @@ -321,9 +338,7 @@ export function DepartmentStructure({

{deptToDelete?.name} 부서를 삭제하시겠습니까?

-

- 이 작업은 되돌릴 수 없습니다. -

+

이 작업은 되돌릴 수 없습니다.

+ + +
); } - From d428a70b69c717fd1878e3e8f7e6e800bcb9df6c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 09:34:22 +0900 Subject: [PATCH 67/76] =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/department/DepartmentMembers.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/department/DepartmentMembers.tsx b/frontend/components/admin/department/DepartmentMembers.tsx index 7147971b..ebccfe9c 100644 --- a/frontend/components/admin/department/DepartmentMembers.tsx +++ b/frontend/components/admin/department/DepartmentMembers.tsx @@ -38,6 +38,7 @@ export function DepartmentMembers({ const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [hasSearched, setHasSearched] = useState(false); // 검색 버튼을 눌렀는지 여부 const [duplicateMessage, setDuplicateMessage] = useState(null); // 부서원 삭제 확인 모달 @@ -75,10 +76,12 @@ export function DepartmentMembers({ const handleSearch = async () => { if (!searchQuery.trim()) { setSearchResults([]); + setHasSearched(false); return; } setIsSearching(true); + setHasSearched(true); // 검색 버튼을 눌렀음을 표시 try { const response = await departmentAPI.searchUsers(companyCode, searchQuery); if (response.success && response.data) { @@ -108,6 +111,7 @@ export function DepartmentMembers({ setIsAddModalOpen(false); setSearchQuery(""); setSearchResults([]); + setHasSearched(false); // 검색 상태 초기화 loadMembers(); onMemberChange?.(); // 부서 구조 새로고침 @@ -341,7 +345,7 @@ export function DepartmentMembers({ {/* 검색 결과 */} {isSearching ? (
검색 중...
- ) : searchResults.length > 0 ? ( + ) : hasSearched && searchResults.length > 0 ? (
{searchResults.map((user) => (
))}
- ) : searchQuery && !isSearching ? ( + ) : hasSearched && searchResults.length === 0 ? (
검색 결과가 없습니다.
@@ -377,6 +381,7 @@ export function DepartmentMembers({ setIsAddModalOpen(false); setSearchQuery(""); setSearchResults([]); + setHasSearched(false); // 검색 상태 초기화 }} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > From 7425c370948726090c02877d7e10e0e16bbe9f60 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 4 Nov 2025 09:41:58 +0900 Subject: [PATCH 68/76] =?UTF-8?q?=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C,=20=EC=97=85=EB=A1=9C=EB=93=9C,=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=EC=B4=AC=EC=98=81(=EB=B0=94=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=94=EA=B8=B0=EB=8A=A5)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/tableManagementService.ts | 3 + .../components/common/BarcodeScanModal.tsx | 371 ++++ .../components/common/ExcelUploadModal.tsx | 349 ++++ .../screen/InteractiveScreenViewerDynamic.tsx | 8 +- .../config-panels/ButtonConfigPanel.tsx | 129 ++ frontend/lib/api/dynamicForm.ts | 122 ++ .../button-primary/ButtonPrimaryComponent.tsx | 16 +- .../table-list/TableListComponent.tsx | 10 +- frontend/lib/utils/buttonActions.ts | 266 ++- frontend/lib/utils/excelExport.ts | 172 ++ frontend/package-lock.json | 1596 ++++++++++------- frontend/package.json | 2 + 12 files changed, 2342 insertions(+), 702 deletions(-) create mode 100644 frontend/components/common/BarcodeScanModal.tsx create mode 100644 frontend/components/common/ExcelUploadModal.tsx create mode 100644 frontend/lib/utils/excelExport.ts diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 608f8b96..df67e2fe 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1502,6 +1502,9 @@ export class TableManagementService { LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; + logger.info(`🔍 실행할 SQL: ${dataQuery}`); + logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`); + let data = await query(dataQuery, [...searchValues, size, offset]); // 🎯 파일 컬럼이 있으면 파일 정보 보강 diff --git a/frontend/components/common/BarcodeScanModal.tsx b/frontend/components/common/BarcodeScanModal.tsx new file mode 100644 index 00000000..69fa9a82 --- /dev/null +++ b/frontend/components/common/BarcodeScanModal.tsx @@ -0,0 +1,371 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react"; +import Webcam from "react-webcam"; +import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library"; + +export interface BarcodeScanModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + targetField?: string; + barcodeFormat?: "all" | "1d" | "2d"; + autoSubmit?: boolean; + onScanSuccess: (barcode: string) => void; +} + +export const BarcodeScanModal: React.FC = ({ + open, + onOpenChange, + targetField, + barcodeFormat = "all", + autoSubmit = false, + onScanSuccess, +}) => { + const [isScanning, setIsScanning] = useState(false); + const [scannedCode, setScannedCode] = useState(""); + const [error, setError] = useState(""); + const [hasPermission, setHasPermission] = useState(null); + const webcamRef = useRef(null); + const codeReaderRef = useRef(null); + const scanIntervalRef = useRef(null); + + // 바코드 리더 초기화 + useEffect(() => { + if (open) { + codeReaderRef.current = new BrowserMultiFormatReader(); + // 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청 + } + + return () => { + stopScanning(); + if (codeReaderRef.current) { + codeReaderRef.current.reset(); + } + }; + }, [open]); + + // 카메라 권한 요청 + const requestCameraPermission = async () => { + console.log("🎥 카메라 권한 요청 시작..."); + + // navigator.mediaDevices 지원 확인 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + console.error("❌ navigator.mediaDevices를 사용할 수 없습니다."); + console.log("현재 프로토콜:", window.location.protocol); + console.log("현재 호스트:", window.location.host); + setHasPermission(false); + setError( + "이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " + + "현재 프로토콜: " + window.location.protocol + ); + toast.error("카메라 접근이 불가능합니다. 콘솔을 확인해주세요."); + return; + } + + try { + console.log("🔄 getUserMedia 호출 중..."); + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + console.log("✅ 카메라 권한 허용됨!"); + setHasPermission(true); + stream.getTracks().forEach((track) => track.stop()); // 권한 확인 후 스트림 종료 + toast.success("카메라 권한이 허용되었습니다."); + } catch (err: any) { + console.error("❌ 카메라 권한 오류:", err); + console.error("에러 이름:", err.name); + console.error("에러 메시지:", err.message); + setHasPermission(false); + + // 에러 타입에 따라 다른 메시지 표시 + if (err.name === "NotAllowedError") { + setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요."); + toast.error("카메라 권한이 거부되었습니다."); + } else if (err.name === "NotFoundError") { + setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요."); + toast.error("카메라를 찾을 수 없습니다."); + } else if (err.name === "NotReadableError") { + setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다."); + toast.error("카메라가 사용 중입니다."); + } else if (err.name === "NotSupportedError") { + setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다."); + toast.error("HTTPS 환경이 필요합니다."); + } else { + setError(`카메라 접근 오류: ${err.name} - ${err.message}`); + toast.error("카메라 접근 중 오류가 발생했습니다."); + } + } + }; + + // 스캔 시작 + const startScanning = () => { + setIsScanning(true); + setError(""); + setScannedCode(""); + + // 주기적으로 스캔 시도 (500ms마다) + scanIntervalRef.current = setInterval(() => { + scanBarcode(); + }, 500); + }; + + // 스캔 중지 + const stopScanning = () => { + setIsScanning(false); + if (scanIntervalRef.current) { + clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; + } + }; + + // 바코드 스캔 + const scanBarcode = async () => { + if (!webcamRef.current || !codeReaderRef.current) return; + + try { + const imageSrc = webcamRef.current.getScreenshot(); + if (!imageSrc) return; + + // 이미지를 HTMLImageElement로 변환 + const img = new Image(); + img.src = imageSrc; + + await new Promise((resolve) => { + img.onload = resolve; + }); + + // 바코드 디코딩 + const result = await codeReaderRef.current.decodeFromImageElement(img); + + if (result) { + const barcode = result.getText(); + console.log("✅ 바코드 스캔 성공:", barcode); + + setScannedCode(barcode); + stopScanning(); + toast.success(`바코드 스캔 완료: ${barcode}`); + + // 자동 제출 옵션이 켜져있으면 바로 콜백 실행 + if (autoSubmit) { + onScanSuccess(barcode); + } + } + } catch (err) { + // NotFoundException은 정상적인 상황 (바코드가 아직 인식되지 않음) + if (!(err instanceof NotFoundException)) { + console.error("바코드 스캔 오류:", err); + } + } + }; + + // 수동 확인 버튼 + const handleConfirm = () => { + if (scannedCode) { + onScanSuccess(scannedCode); + } else { + toast.error("스캔된 바코드가 없습니다."); + } + }; + + return ( + + + + 바코드 스캔 + + 카메라로 바코드를 스캔하세요. + {targetField && ` (대상 필드: ${targetField})`} + + + +
+ {/* 카메라 권한 요청 대기 중 */} + {hasPermission === null && ( +
+
+ +
+
+

카메라 권한이 필요합니다

+

+ 바코드를 스캔하려면 카메라 접근 권한을 허용해주세요. +

+
+ +
+

💡 권한 요청 안내:

+
    +
  • 아래 버튼을 클릭하면 브라우저에서 권한 요청 팝업이 표시됩니다
  • +
  • 팝업에서 "허용" 버튼을 클릭해주세요
  • +
  • 권한은 언제든지 브라우저 설정에서 변경할 수 있습니다
  • +
+
+ +
+ +
+
+
+
+ )} + + {/* 카메라 권한 거부됨 */} + {hasPermission === false && ( +
+
+ +
+
+

카메라 접근 권한이 필요합니다

+

{error}

+
+ +
+

📱 권한 허용 방법:

+
    +
  1. 브라우저 주소창 왼쪽의 🔒 자물쇠 아이콘을 클릭하세요
  2. +
  3. "카메라" 항목을 찾아 "허용"으로 변경하세요
  4. +
  5. 페이지를 새로고침하거나 다시 스캔을 시도하세요
  6. +
+
+ +
+ +
+
+
+
+ )} + + {/* 웹캠 뷰 */} + {hasPermission && ( +
+ + + {/* 스캔 가이드 오버레이 */} + {isScanning && ( +
+
+
+
+ + 스캔 중... +
+
+
+ )} + + {/* 스캔 완료 오버레이 */} + {scannedCode && ( +
+
+ +

스캔 완료!

+

{scannedCode}

+
+
+ )} +
+ )} + + {/* 바코드 포맷 정보 */} +
+
+ +
+

지원 포맷

+

+ {barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"} + {barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"} + {barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"} +

+
+
+
+ + {/* 에러 메시지 */} + {error && ( +
+
+ +

{error}

+
+
+ )} +
+ + + + + {!isScanning && !scannedCode && hasPermission && ( + + )} + + {isScanning && ( + + )} + + {scannedCode && !autoSubmit && ( + + )} + + +
+ ); +}; + diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx new file mode 100644 index 00000000..6f909357 --- /dev/null +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -0,0 +1,349 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2 } from "lucide-react"; +import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; +import { DynamicFormApi } from "@/lib/api/dynamicForm"; + +export interface ExcelUploadModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tableName: string; + uploadMode?: "insert" | "update" | "upsert"; + keyColumn?: string; + onSuccess?: () => void; +} + +export const ExcelUploadModal: React.FC = ({ + open, + onOpenChange, + tableName, + uploadMode = "insert", + keyColumn, + onSuccess, +}) => { + const [file, setFile] = useState(null); + const [sheetNames, setSheetNames] = useState([]); + const [selectedSheet, setSelectedSheet] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const [previewData, setPreviewData] = useState[]>([]); + const fileInputRef = useRef(null); + + // 파일 선택 핸들러 + const handleFileChange = async (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + // 파일 확장자 검증 + const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); + if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { + toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); + return; + } + + setFile(selectedFile); + + try { + // 시트 목록 가져오기 + const sheets = await getExcelSheetNames(selectedFile); + setSheetNames(sheets); + setSelectedSheet(sheets[0] || ""); + + // 미리보기 데이터 로드 (첫 5행) + const data = await importFromExcel(selectedFile, sheets[0]); + setPreviewData(data.slice(0, 5)); + + toast.success(`파일이 선택되었습니다: ${selectedFile.name}`); + } catch (error) { + console.error("파일 읽기 오류:", error); + toast.error("파일을 읽는 중 오류가 발생했습니다."); + setFile(null); + } + }; + + // 시트 변경 핸들러 + const handleSheetChange = async (sheetName: string) => { + setSelectedSheet(sheetName); + + if (!file) return; + + try { + const data = await importFromExcel(file, sheetName); + setPreviewData(data.slice(0, 5)); + } catch (error) { + console.error("시트 읽기 오류:", error); + toast.error("시트를 읽는 중 오류가 발생했습니다."); + } + }; + + // 업로드 핸들러 + const handleUpload = async () => { + if (!file) { + toast.error("파일을 선택해주세요."); + return; + } + + if (!tableName) { + toast.error("테이블명이 지정되지 않았습니다."); + return; + } + + setIsUploading(true); + + try { + // 엑셀 데이터 읽기 + const data = await importFromExcel(file, selectedSheet); + + console.log("📤 엑셀 업로드 시작:", { + tableName, + uploadMode, + rowCount: data.length, + }); + + // 업로드 모드에 따라 처리 + let successCount = 0; + let failCount = 0; + + for (const row of data) { + try { + if (uploadMode === "insert") { + // 삽입 모드 + const formData = { screenId: 0, tableName, data: row }; + const result = await DynamicFormApi.saveFormData(formData); + if (result.success) { + successCount++; + } else { + console.error("저장 실패:", result.message, row); + failCount++; + } + } else if (uploadMode === "update" && keyColumn) { + // 업데이트 모드 + const keyValue = row[keyColumn]; + if (keyValue) { + await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row); + successCount++; + } else { + failCount++; + } + } else if (uploadMode === "upsert" && keyColumn) { + // Upsert 모드 (있으면 업데이트, 없으면 삽입) + const keyValue = row[keyColumn]; + if (keyValue) { + try { + const updateResult = await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row); + if (!updateResult.success) { + // 업데이트 실패 시 삽입 시도 + const formData = { screenId: 0, tableName, data: row }; + const insertResult = await DynamicFormApi.saveFormData(formData); + if (insertResult.success) { + successCount++; + } else { + console.error("Upsert 실패:", insertResult.message, row); + failCount++; + } + } else { + successCount++; + } + } catch { + const formData = { screenId: 0, tableName, data: row }; + const insertResult = await DynamicFormApi.saveFormData(formData); + if (insertResult.success) { + successCount++; + } else { + console.error("Upsert 실패:", insertResult.message, row); + failCount++; + } + } + } else { + const formData = { screenId: 0, tableName, data: row }; + const result = await DynamicFormApi.saveFormData(formData); + if (result.success) { + successCount++; + } else { + console.error("저장 실패:", result.message, row); + failCount++; + } + } + } + } catch (error) { + console.error("행 처리 오류:", row, error); + failCount++; + } + } + + console.log("✅ 엑셀 업로드 완료:", { + successCount, + failCount, + totalCount: data.length, + }); + + if (successCount > 0) { + toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`); + // onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음 + onSuccess?.(); + // onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음 + } else { + toast.error("업로드에 실패했습니다."); + } + } catch (error) { + console.error("❌ 엑셀 업로드 실패:", error); + toast.error("엑셀 업로드 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + return ( + + + + 엑셀 파일 업로드 + + 엑셀 파일을 선택하여 데이터를 업로드하세요. + + + +
+ {/* 파일 선택 */} +
+ +
+ + +
+

+ 지원 형식: .xlsx, .xls, .csv +

+
+ + {/* 시트 선택 */} + {sheetNames.length > 0 && ( +
+ + +
+ )} + + {/* 업로드 모드 정보 */} +
+
+ +
+

업로드 모드: {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}

+

+ {uploadMode === "insert" && "새로운 데이터로 삽입됩니다."} + {uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`} + {uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`} +

+
+
+
+ + {/* 미리보기 */} + {previewData.length > 0 && ( +
+ +
+
+ + + {Object.keys(previewData[0]).map((key) => ( + + ))} + + + + {previewData.map((row, index) => ( + + {Object.values(row).map((value, i) => ( + + ))} + + ))} + +
+ {key} +
+ {String(value)} +
+
+
+ + 총 {previewData.length}개 행 (미리보기) +
+
+ )} +
+ + + + + + + + ); +}; + diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 8df068eb..baa4ce83 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -142,15 +142,11 @@ export const InteractiveScreenViewerDynamic: React.FC { - // console.log(`🎯 InteractiveScreenViewerDynamic handleFormDataChange 호출: ${fieldName} = "${value}"`); - // console.log(`📋 onFormDataChange 존재 여부:`, !!onFormDataChange); - + const handleFormDataChange = (fieldName: string | any, value?: any) => { + // 일반 필드 변경 if (onFormDataChange) { - // console.log(`📤 InteractiveScreenViewerDynamic -> onFormDataChange 호출: ${fieldName} = "${value}"`); onFormDataChange(fieldName, value); } else { - // console.log(`💾 InteractiveScreenViewerDynamic 로컬 상태 업데이트: ${fieldName} = "${value}"`); setLocalFormData((prev) => ({ ...prev, [fieldName]: value })); } }; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index d7d33e4a..5e1471ca 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -267,6 +267,9 @@ export const ButtonConfigPanel: React.FC = ({ 모달 열기 제어 흐름 테이블 이력 보기 + 엑셀 다운로드 + 엑셀 업로드 + 바코드 스캔
@@ -709,6 +712,132 @@ export const ButtonConfigPanel: React.FC = ({
)} + {/* 엑셀 다운로드 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "excel_download" && ( +
+

엑셀 다운로드 설정

+ +
+ + onUpdateProperty("componentConfig.action.excelFileName", e.target.value)} + className="h-8 text-xs" + /> +

확장자(.xlsx)는 자동으로 추가됩니다

+
+ +
+ + onUpdateProperty("componentConfig.action.excelSheetName", e.target.value)} + className="h-8 text-xs" + /> +
+ +
+ + onUpdateProperty("componentConfig.action.excelIncludeHeaders", checked)} + /> +
+
+ )} + + {/* 엑셀 업로드 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "excel_upload" && ( +
+

📤 엑셀 업로드 설정

+ +
+ + +
+ + {(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && ( +
+ + onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)} + className="h-8 text-xs" + /> +

UPDATE/UPSERT 시 기준이 되는 컬럼명

+
+ )} +
+ )} + + {/* 바코드 스캔 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "barcode_scan" && ( +
+

📷 바코드 스캔 설정

+ +
+ + onUpdateProperty("componentConfig.action.barcodeTargetField", e.target.value)} + className="h-8 text-xs" + /> +

스캔 결과가 입력될 폼 필드명

+
+ +
+ + +
+ +
+ + onUpdateProperty("componentConfig.action.barcodeAutoSubmit", checked)} + /> +
+
+ )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index 77bd4606..455ab5eb 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -410,6 +410,128 @@ export class DynamicFormApi { }; } } + + /** + * 테이블 데이터 조회 (페이징 + 검색) + * @param tableName 테이블명 + * @param params 검색 파라미터 + * @returns 테이블 데이터 + */ + static async getTableData( + tableName: string, + params?: { + page?: number; + pageSize?: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + filters?: Record; + }, + ): Promise> { + try { + console.log("📊 테이블 데이터 조회 요청:", { tableName, params }); + + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {}); + + console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data); + console.log("🔍 response.data 상세:", { + type: typeof response.data, + isArray: Array.isArray(response.data), + keys: response.data ? Object.keys(response.data) : [], + hasData: response.data?.data !== undefined, + dataType: response.data?.data ? typeof response.data.data : "N/A", + dataIsArray: response.data?.data ? Array.isArray(response.data.data) : false, + dataLength: response.data?.data ? (Array.isArray(response.data.data) ? response.data.data.length : "not array") : "no data", + // 중첩 구조 확인 + dataDataExists: response.data?.data?.data !== undefined, + dataDataIsArray: response.data?.data?.data ? Array.isArray(response.data.data.data) : false, + dataDataLength: response.data?.data?.data ? (Array.isArray(response.data.data.data) ? response.data.data.data.length : "not array") : "no nested data", + }); + + // API 응답 구조: { data: [...], total, page, size, totalPages } + // 또는 중첩: { success: true, data: { data: [...], total, ... } } + // data 배열만 추출 + let tableData: any[] = []; + + if (Array.isArray(response.data)) { + // 케이스 1: 응답이 배열이면 그대로 사용 + console.log("✅ 케이스 1: 응답이 배열"); + tableData = response.data; + } else if (response.data && Array.isArray(response.data.data)) { + // 케이스 2: 응답이 { data: [...] } 구조면 data 배열 추출 + console.log("✅ 케이스 2: 응답이 { data: [...] } 구조"); + tableData = response.data.data; + } else if (response.data?.data?.data && Array.isArray(response.data.data.data)) { + // 케이스 2-1: 중첩 구조 { success: true, data: { data: [...] } } + console.log("✅ 케이스 2-1: 중첩 구조 { data: { data: [...] } }"); + tableData = response.data.data.data; + } else if (response.data && typeof response.data === "object") { + // 케이스 3: 응답이 객체면 배열로 감싸기 (최후의 수단) + console.log("⚠️ 케이스 3: 응답이 객체 (배열로 감싸기)"); + tableData = [response.data]; + } + + console.log("✅ 테이블 데이터 추출 완료:", { + originalType: typeof response.data, + isArray: Array.isArray(response.data), + hasDataProperty: response.data?.data !== undefined, + extractedCount: tableData.length, + firstRow: tableData[0], + allRows: tableData, + }); + + return { + success: true, + data: tableData, + message: "테이블 데이터 조회가 완료되었습니다.", + }; + } catch (error: any) { + console.error("❌ 테이블 데이터 조회 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "테이블 데이터 조회 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 엑셀 업로드 (대량 데이터 삽입/업데이트) + * @param payload 업로드 데이터 + * @returns 업로드 결과 + */ + static async uploadExcelData(payload: { + tableName: string; + data: any[]; + uploadMode: "insert" | "update" | "upsert"; + keyColumn?: string; + }): Promise> { + try { + console.log("📤 엑셀 업로드 요청:", payload); + + const response = await apiClient.post(`/dynamic-form/excel-upload`, payload); + + console.log("✅ 엑셀 업로드 성공:", response.data); + return { + success: true, + data: response.data, + message: "엑셀 파일이 성공적으로 업로드되었습니다.", + }; + } catch (error: any) { + console.error("❌ 엑셀 업로드 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "엑셀 업로드 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } } // 편의를 위한 기본 export diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index f2d6e554..93d96ca0 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -247,8 +247,8 @@ export const ButtonPrimaryComponent: React.FC = ({ // 추가 안전장치: 모든 로딩 토스트 제거 toast.dismiss(); - // UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시 - const silentActions = ["edit", "modal", "navigate"]; + // UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 + const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; if (!silentActions.includes(actionConfig.type)) { currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" @@ -274,9 +274,9 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 if (!success) { - // UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리 - const silentActions = ["edit", "modal", "navigate"]; - if (silentActions.includes(actionConfig.type)) { + // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) + const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (silentErrorActions.includes(actionConfig.type)) { return; } // 기본 에러 메시지 결정 @@ -302,8 +302,10 @@ export const ButtonPrimaryComponent: React.FC = ({ } // 성공한 경우에만 성공 토스트 표시 - // edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요) - if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") { + // edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리 + // (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시) + const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (!silentSuccessActions.includes(actionConfig.type)) { // 기본 성공 메시지 결정 const defaultSuccessMessage = actionConfig.type === "save" diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 465bb5ce..4e5243b6 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -531,7 +531,10 @@ export const TableListComponent: React.FC = ({ onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData); } if (onFormDataChange) { - onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData }); + onFormDataChange({ + selectedRows: Array.from(newSelectedRows), + selectedRowsData, + }); } const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index))); @@ -549,7 +552,10 @@ export const TableListComponent: React.FC = ({ onSelectedRowsChange(Array.from(newSelectedRows), data); } if (onFormDataChange) { - onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data }); + onFormDataChange({ + selectedRows: Array.from(newSelectedRows), + selectedRowsData: data, + }); } } else { setSelectedRows(new Set()); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index eb5046e3..753deea5 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -17,7 +17,10 @@ export type ButtonActionType = | "navigate" // 페이지 이동 | "modal" // 모달 열기 | "control" // 제어 흐름 - | "view_table_history"; // 테이블 이력 보기 + | "view_table_history" // 테이블 이력 보기 + | "excel_download" // 엑셀 다운로드 + | "excel_upload" // 엑셀 업로드 + | "barcode_scan"; // 바코드 스캔 /** * 버튼 액션 설정 @@ -56,6 +59,20 @@ export interface ButtonActionConfig { historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스 historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항) historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name) + + // 엑셀 다운로드 관련 + excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx) + excelSheetName?: string; // 시트명 (기본: "Sheet1") + excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true) + + // 엑셀 업로드 관련 + excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드 + excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼 + + // 바코드 스캔 관련 + barcodeTargetField?: string; // 스캔 결과를 입력할 필드명 + barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all") + barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부 } /** @@ -121,6 +138,15 @@ export class ButtonActionExecutor { case "view_table_history": return this.handleViewTableHistory(config, context); + case "excel_download": + return await this.handleExcelDownload(config, context); + + case "excel_upload": + return await this.handleExcelUpload(config, context); + + case "barcode_scan": + return await this.handleBarcodeScan(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -1646,6 +1672,226 @@ export class ButtonActionExecutor { } } + /** + * 엑셀 다운로드 액션 처리 + */ + private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📥 엑셀 다운로드 시작:", { config, context }); + + // 동적 import로 엑셀 유틸리티 로드 + const { exportToExcel } = await import("@/lib/utils/excelExport"); + + let dataToExport: any[] = []; + + // 1순위: 선택된 행 데이터 + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + dataToExport = context.selectedRowsData; + console.log("✅ 선택된 행 데이터 사용:", dataToExport.length); + } + // 2순위: 테이블 전체 데이터 (API 호출) + else if (context.tableName) { + console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName); + try { + const { dynamicFormApi } = await import("@/lib/api/dynamicForm"); + const response = await dynamicFormApi.getTableData(context.tableName, { + page: 1, + pageSize: 10000, // 최대 10,000개 행 + sortBy: "id", // 기본 정렬: id 컬럼 + sortOrder: "asc", // 오름차순 + }); + + console.log("📦 API 응답 구조:", { + response, + responseSuccess: response.success, + responseData: response.data, + responseDataType: typeof response.data, + responseDataIsArray: Array.isArray(response.data), + responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A", + }); + + if (response.success && response.data) { + dataToExport = response.data; + console.log("✅ 테이블 전체 데이터 조회 완료:", { + count: dataToExport.length, + firstRow: dataToExport[0], + }); + } else { + console.error("❌ API 응답에 데이터가 없습니다:", response); + } + } catch (error) { + console.error("❌ 테이블 데이터 조회 실패:", error); + } + } + // 4순위: 폼 데이터 + else if (context.formData && Object.keys(context.formData).length > 0) { + dataToExport = [context.formData]; + console.log("✅ 폼 데이터 사용:", dataToExport); + } + + console.log("📊 최종 다운로드 데이터:", { + selectedRowsData: context.selectedRowsData, + selectedRowsLength: context.selectedRowsData?.length, + formData: context.formData, + tableName: context.tableName, + dataToExport, + dataToExportType: typeof dataToExport, + dataToExportIsArray: Array.isArray(dataToExport), + dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A", + }); + + // 배열이 아니면 배열로 변환 + if (!Array.isArray(dataToExport)) { + console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport); + + // 객체인 경우 배열로 감싸기 + if (typeof dataToExport === "object" && dataToExport !== null) { + dataToExport = [dataToExport]; + } else { + toast.error("다운로드할 데이터 형식이 올바르지 않습니다."); + return false; + } + } + + if (dataToExport.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return false; + } + + // 파일명 생성 + const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`; + const sheetName = config.excelSheetName || "Sheet1"; + const includeHeaders = config.excelIncludeHeaders !== false; + + console.log("📥 엑셀 다운로드 실행:", { + fileName, + sheetName, + includeHeaders, + dataCount: dataToExport.length, + firstRow: dataToExport[0], + }); + + // 엑셀 다운로드 실행 + await exportToExcel(dataToExport, fileName, sheetName, includeHeaders); + + toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다."); + return true; + } catch (error) { + console.error("❌ 엑셀 다운로드 실패:", error); + toast.error(config.errorMessage || "엑셀 다운로드 중 오류가 발생했습니다."); + return false; + } + } + + /** + * 엑셀 업로드 액션 처리 + */ + private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📤 엑셀 업로드 모달 열기:", { config, context }); + + // 동적 import로 모달 컴포넌트 로드 + const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); + const { createRoot } = await import("react-dom/client"); + + // 모달 컨테이너 생성 + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(ExcelUploadModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + tableName: context.tableName || "", + uploadMode: config.excelUploadMode || "insert", + keyColumn: config.excelKeyColumn, + onSuccess: () => { + // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 + context.onRefresh?.(); + closeModal(); + }, + }), + ); + + return true; + } catch (error) { + console.error("❌ 엑셀 업로드 모달 열기 실패:", error); + toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다."); + return false; + } + } + + /** + * 바코드 스캔 액션 처리 + */ + private static async handleBarcodeScan(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📷 바코드 스캔 모달 열기:", { config, context }); + + // 동적 import로 모달 컴포넌트 로드 + const { BarcodeScanModal } = await import("@/components/common/BarcodeScanModal"); + const { createRoot } = await import("react-dom/client"); + + // 모달 컨테이너 생성 + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(BarcodeScanModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + targetField: config.barcodeTargetField, + barcodeFormat: config.barcodeFormat || "all", + autoSubmit: config.barcodeAutoSubmit || false, + onScanSuccess: (barcode: string) => { + console.log("✅ 바코드 스캔 성공:", barcode); + + // 대상 필드에 값 입력 + if (config.barcodeTargetField && context.onFormDataChange) { + context.onFormDataChange({ + ...context.formData, + [config.barcodeTargetField]: barcode, + }); + } + + toast.success(`바코드 스캔 완료: ${barcode}`); + + // 자동 제출 옵션이 켜져있으면 저장 + if (config.barcodeAutoSubmit) { + this.handleSave(config, context); + } + + closeModal(); + }, + }), + ); + + return true; + } catch (error) { + console.error("❌ 바코드 스캔 모달 열기 실패:", error); + toast.error("바코드 스캔 중 오류가 발생했습니다."); + return false; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -1717,4 +1963,22 @@ export const DEFAULT_BUTTON_ACTIONS: Record[], + fileName: string = "export.xlsx", + sheetName: string = "Sheet1", + includeHeaders: boolean = true +): Promise { + try { + console.log("📥 엑셀 내보내기 시작:", { + dataCount: data.length, + fileName, + sheetName, + includeHeaders, + }); + + if (data.length === 0) { + throw new Error("내보낼 데이터가 없습니다."); + } + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + + // 데이터를 워크시트로 변환 + const worksheet = XLSX.utils.json_to_sheet(data, { + header: includeHeaders ? undefined : [], + skipHeader: !includeHeaders, + }); + + // 컬럼 너비 자동 조정 + const columnWidths = autoSizeColumns(data); + worksheet["!cols"] = columnWidths; + + // 워크시트를 워크북에 추가 + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, fileName); + + console.log("✅ 엑셀 내보내기 완료:", fileName); + } catch (error) { + console.error("❌ 엑셀 내보내기 실패:", error); + throw error; + } +} + +/** + * 컬럼 너비 자동 조정 + */ +function autoSizeColumns(data: Record[]): Array<{ wch: number }> { + if (data.length === 0) return []; + + const keys = Object.keys(data[0]); + const columnWidths: Array<{ wch: number }> = []; + + keys.forEach((key) => { + // 헤더 길이 + let maxWidth = key.length; + + // 데이터 길이 확인 + data.forEach((row) => { + const value = row[key]; + const valueLength = value ? String(value).length : 0; + maxWidth = Math.max(maxWidth, valueLength); + }); + + // 최소 10, 최대 50으로 제한 + columnWidths.push({ wch: Math.min(Math.max(maxWidth, 10), 50) }); + }); + + return columnWidths; +} + +/** + * 엑셀 파일을 읽어서 JSON 데이터로 변환 + * @param file 읽을 파일 + * @param sheetName 읽을 시트명 (기본: 첫 번째 시트) + * @returns JSON 데이터 배열 + */ +export async function importFromExcel( + file: File, + sheetName?: string +): Promise[]> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + if (!data) { + reject(new Error("파일을 읽을 수 없습니다.")); + return; + } + + // 워크북 읽기 + const workbook = XLSX.read(data, { type: "binary" }); + + // 시트 선택 (지정된 시트 또는 첫 번째 시트) + const targetSheetName = sheetName || workbook.SheetNames[0]; + const worksheet = workbook.Sheets[targetSheetName]; + + if (!worksheet) { + reject(new Error(`시트 "${targetSheetName}"를 찾을 수 없습니다.`)); + return; + } + + // JSON으로 변환 + const jsonData = XLSX.utils.sheet_to_json(worksheet); + + console.log("✅ 엑셀 가져오기 완료:", { + sheetName: targetSheetName, + rowCount: jsonData.length, + }); + + resolve(jsonData as Record[]); + } catch (error) { + console.error("❌ 엑셀 가져오기 실패:", error); + reject(error); + } + }; + + reader.onerror = () => { + reject(new Error("파일 읽기 중 오류가 발생했습니다.")); + }; + + reader.readAsBinaryString(file); + }); +} + +/** + * 엑셀 파일의 시트 목록 가져오기 + */ +export async function getExcelSheetNames(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + if (!data) { + reject(new Error("파일을 읽을 수 없습니다.")); + return; + } + + const workbook = XLSX.read(data, { type: "binary" }); + resolve(workbook.SheetNames); + } catch (error) { + console.error("❌ 시트 목록 가져오기 실패:", error); + reject(error); + } + }; + + reader.onerror = () => { + reject(new Error("파일 읽기 중 오류가 발생했습니다.")); + }; + + reader.readAsBinaryString(file); + }); +} + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5458073c..08f030e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", + "@zxing/library": "^0.21.3", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -69,6 +70,7 @@ "react-hot-toast": "^2.6.0", "react-leaflet": "^5.0.0", "react-resizable-panels": "^3.0.6", + "react-webcam": "^7.2.0", "react-window": "^2.1.0", "reactflow": "^11.11.4", "recharts": "^3.2.1", @@ -100,6 +102,12 @@ "typescript": "^5" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.19", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.19.tgz", + "integrity": "sha512-Pp2gAQXPZ2o7lt4j0IMwNRXqQ3pagxtDj5wctL5U2Lz4oV0ocDNlkgx4DpxfyKav4S/bePuI+SMqcBSUHLy9kg==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -127,9 +135,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.6.2.tgz", - "integrity": "sha512-+AG0jN9HTwfDLBhjhX1FKi6zlIAc/YGgEHlN/OMaHD1pOPFsC5CpYQpLkPX0aFjyaVmoq9330cQDCU4qnSL1qA==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", @@ -246,9 +254,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz", + "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==", "funding": [ { "type": "github", @@ -262,9 +270,6 @@ "license": "MIT-0", "engines": { "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" } }, "node_modules/@csstools/css-tokenizer": { @@ -352,9 +357,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", "dev": true, "license": "MIT", "optional": true, @@ -364,9 +369,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", "license": "MIT", "optional": true, "dependencies": { @@ -417,9 +422,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -427,13 +432,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -442,19 +447,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -489,9 +497,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz", + "integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==", "dev": true, "license": "MIT", "engines": { @@ -502,9 +510,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -512,13 +520,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1055,19 +1063,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1355,66 +1350,66 @@ } }, "node_modules/@prisma/config": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz", - "integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz", + "integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.16.12", + "effect": "3.18.4", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz", - "integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz", + "integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz", - "integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz", + "integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.16.2", - "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", - "@prisma/fetch-engine": "6.16.2", - "@prisma/get-platform": "6.16.2" + "@prisma/debug": "6.18.0", + "@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", + "@prisma/fetch-engine": "6.18.0", + "@prisma/get-platform": "6.18.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz", - "integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==", + "version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz", + "integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz", - "integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz", + "integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.16.2", - "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", - "@prisma/get-platform": "6.16.2" + "@prisma/debug": "6.18.0", + "@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", + "@prisma/get-platform": "6.18.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz", - "integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz", + "integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.16.2" + "@prisma/debug": "6.18.0" } }, "node_modules/@radix-ui/number": { @@ -2571,35 +2566,6 @@ } } }, - "node_modules/@react-three/drei/node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/@react-three/fiber": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", @@ -2650,41 +2616,6 @@ } } }, - "node_modules/@react-three/fiber/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, - "node_modules/@react-three/fiber/node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -2700,6 +2631,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/background/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/controls": { "version": "11.2.14", "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", @@ -2715,6 +2674,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/controls/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/core": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", @@ -2736,6 +2723,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/core/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/minimap": { "version": "11.7.14", "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", @@ -2755,6 +2770,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/minimap/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/node-resizer": { "version": "2.2.14", "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", @@ -2772,6 +2815,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/node-resizer/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/node-toolbar": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", @@ -2787,28 +2858,30 @@ "react-dom": ">=17" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", - "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "node_modules/@reactflow/node-toolbar/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" }, "peerDependenciesMeta": { - "react": { + "@types/react": { "optional": true }, - "react-redux": { + "immer": { + "optional": true + }, + "react": { "optional": true } } @@ -2821,9 +2894,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.1.tgz", + "integrity": "sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==", "dev": true, "license": "MIT" }, @@ -2849,54 +2922,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", - "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", - "jiti": "^2.5.1", - "lightningcss": "1.30.1", - "magic-string": "^0.30.18", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.19", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.13" + "tailwindcss": "4.1.16" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", - "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-x64": "4.1.13", - "@tailwindcss/oxide-freebsd-x64": "4.1.13", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-x64-musl": "4.1.13", - "@tailwindcss/oxide-wasm32-wasi": "4.1.13", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", - "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", "cpu": [ "arm64" ], @@ -2911,9 +2979,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", - "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", "cpu": [ "arm64" ], @@ -2928,9 +2996,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", - "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", "cpu": [ "x64" ], @@ -2945,9 +3013,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", - "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", "cpu": [ "x64" ], @@ -2962,9 +3030,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", - "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", "cpu": [ "arm" ], @@ -2979,9 +3047,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", - "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", "cpu": [ "arm64" ], @@ -2996,9 +3064,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", - "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", "cpu": [ "arm64" ], @@ -3013,9 +3081,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", - "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", "cpu": [ "x64" ], @@ -3030,9 +3098,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", - "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", "cpu": [ "x64" ], @@ -3047,9 +3115,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", - "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3065,21 +3133,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.5", - "@emnapi/runtime": "^1.4.5", - "@emnapi/wasi-threads": "^1.0.4", - "@napi-rs/wasm-runtime": "^0.2.12", - "@tybys/wasm-util": "^0.10.0", - "tslib": "^2.8.0" + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", - "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", "cpu": [ "arm64" ], @@ -3094,9 +3162,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", - "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", "cpu": [ "x64" ], @@ -3111,23 +3179,23 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", - "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz", + "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.13", - "@tailwindcss/oxide": "4.1.13", + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", "postcss": "^8.4.41", - "tailwindcss": "4.1.13" + "tailwindcss": "4.1.16" } }, "node_modules/@tanstack/query-core": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", - "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", + "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", "license": "MIT", "funding": { "type": "github", @@ -3146,12 +3214,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", - "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", + "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.2" + "@tanstack/query-core": "5.90.6" }, "funding": { "type": "github", @@ -3597,21 +3665,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/buffer/node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" - }, - "node_modules/@turf/buffer/node_modules/d3-geo": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", - "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1" - } - }, "node_modules/@turf/center": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/center/-/center-7.2.0.tgz", @@ -5573,9 +5626,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", - "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5602,22 +5655,22 @@ "optional": true }, "node_modules/@types/react": { - "version": "19.1.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", - "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", - "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-reconciler": { @@ -5686,17 +5739,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", - "integrity": "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/type-utils": "8.44.1", - "@typescript-eslint/utils": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -5710,7 +5763,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.44.1", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -5726,16 +5779,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", - "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" }, "engines": { @@ -5751,14 +5804,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", - "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.44.1", - "@typescript-eslint/types": "^8.44.1", + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" }, "engines": { @@ -5773,14 +5826,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", - "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1" + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5791,9 +5844,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", - "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", "dev": true, "license": "MIT", "engines": { @@ -5808,15 +5861,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz", - "integrity": "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/utils": "8.44.1", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5833,9 +5886,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", - "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", "dev": true, "license": "MIT", "engines": { @@ -5847,16 +5900,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", - "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.44.1", - "@typescript-eslint/tsconfig-utils": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5932,16 +5985,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", - "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1" + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5956,13 +6009,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", - "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6276,12 +6329,12 @@ } }, "node_modules/@xyflow/react": { - "version": "12.8.5", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.5.tgz", - "integrity": "sha512-NRwcE8QE7dh6BbaIT7GmNccP7/RMDZJOKtzK4HQw599TAfzC8e5E/zw/7MwtpnSbbkqBYc+jZyOisd57sp/hPQ==", + "version": "12.9.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz", + "integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.69", + "@xyflow/system": "0.0.72", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -6290,10 +6343,38 @@ "react-dom": ">=17" } }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@xyflow/system": { - "version": "0.0.69", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.69.tgz", - "integrity": "sha512-+KYwHDnsapZQ1xSgsYwOKYN93fUR770LwfCT5qrvcmzoMaabO1rHa6twiEk7E5VUIceWciF8ukgfq9JC83B5jQ==", + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", + "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -6307,6 +6388,28 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6617,9 +6720,9 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -6627,9 +6730,9 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -6857,9 +6960,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001753", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", + "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", "funding": [ { "type": "opencollective", @@ -6951,16 +7054,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -7075,10 +7168,13 @@ } }, "node_modules/commander": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", - "license": "MIT" + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -7099,12 +7195,6 @@ "tinyqueue": "^2.0.3" } }, - "node_modules/concaveman/node_modules/robust-predicates": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", - "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", - "license": "Unlicense" - }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -7207,9 +7297,9 @@ } }, "node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.2.tgz", + "integrity": "sha512-zDMqXh8Vs1CdRYZQ2M633m/SFgcjlu8RB8b/1h82i+6vpArF507NSYIWJHGlJaTWoS+imcnctmEz43txhbVkOw==", "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.0.3", @@ -7396,15 +7486,6 @@ "node": ">=12" } }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -7450,17 +7531,20 @@ } }, "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", + "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", + "license": "BSD-3-Clause", "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" + "d3-array": "1" } }, + "node_modules/d3-geo/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, "node_modules/d3-hierarchy": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", @@ -7642,6 +7726,18 @@ "node": ">=12" } }, + "node_modules/d3/node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7830,6 +7926,12 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delaunator/node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7856,9 +7958,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -7888,15 +7990,6 @@ "redux": "^4.2.0" } }, - "node_modules/dnd-core/node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7928,45 +8021,27 @@ } }, "node_modules/docx-preview": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz", - "integrity": "sha512-gKVPE18hlpfuhQHiptsw1rbOwzQeGSwK10/w7hv1ZMEqHmjtCuTpz6AUMfu1twIPGxgpcsMXThKI6B6WsP3L1w==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz", + "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==", "license": "Apache-2.0", "dependencies": { "jszip": ">=3.0.0" } }, "node_modules/docx/node_modules/@types/node": { - "version": "24.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz", - "integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "license": "MIT", "dependencies": { - "undici-types": "~7.13.0" - } - }, - "node_modules/docx/node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" + "undici-types": "~7.16.0" } }, "node_modules/docx/node_modules/undici-types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", - "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/dompurify": { @@ -8027,9 +8102,9 @@ "license": "ISC" }, "node_modules/effect": { - "version": "3.16.12", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", - "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==", + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", "dev": true, "license": "MIT", "dependencies": { @@ -8254,9 +8329,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.41.0.tgz", + "integrity": "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==", "license": "MIT", "workspaces": [ "docs", @@ -8277,25 +8352,24 @@ } }, "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz", + "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.0", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -8862,12 +8936,6 @@ "pako": "^2.1.0" } }, - "node_modules/fast-png/node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -9049,6 +9117,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/geojson-equality-ts": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/geojson-equality-ts/-/geojson-equality-ts-1.0.2.tgz", @@ -9147,9 +9225,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9227,9 +9305,9 @@ "license": "MIT" }, "node_modules/goober": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", - "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", "license": "MIT", "peerDependencies": { "csstype": "^3.0.10" @@ -9363,9 +9441,9 @@ } }, "node_modules/hls.js": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz", - "integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==", + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.14.tgz", + "integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==", "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { @@ -9377,6 +9455,12 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -9483,9 +9567,9 @@ "license": "MIT" }, "node_modules/immer": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", - "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", "funding": { "type": "opencollective", @@ -9727,14 +9811,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -9969,10 +10054,9 @@ } }, "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, "node_modules/isexe": { @@ -9982,16 +10066,16 @@ "license": "ISC" }, "node_modules/isomorphic-dompurify": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.28.0.tgz", - "integrity": "sha512-9G5v8g4tYoix5odskjG704Khm1zNrqqqOC4YjCwEUhx0OvuaijRCprAV2GwJ9iw/01c6H1R+rs/2AXPZLlgDaQ==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.31.0.tgz", + "integrity": "sha512-/XPACpfVJeEiy28UgkBWUWdhgKN8xwFYkoVFsqrcSJJ5pXZ3HStuF3ih/Hr8PwhCXHqFAys+b4tcgw0pbUT4rw==", "license": "MIT", "dependencies": { - "dompurify": "^3.2.7", - "jsdom": "^27.0.0" + "dompurify": "^3.3.0", + "jsdom": "^27.1.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.5" } }, "node_modules/iterator.prototype": { @@ -10034,9 +10118,9 @@ } }, "node_modules/jiti": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", - "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -10064,21 +10148,21 @@ } }, "node_modules/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz", + "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^6.5.4", - "cssstyle": "^5.3.0", + "@acemir/cssom": "^0.9.19", + "@asamuzakjp/dom-selector": "^6.7.3", + "cssstyle": "^5.3.2", "data-urls": "^6.0.0", - "decimal.js": "^10.5.0", + "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.3.0", - "rrweb-cssom": "^0.8.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", @@ -10086,12 +10170,12 @@ "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0", - "ws": "^8.18.2", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -10190,6 +10274,12 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10250,9 +10340,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -10266,22 +10356,44 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "cpu": [ "arm64" ], @@ -10300,9 +10412,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "cpu": [ "x64" ], @@ -10321,9 +10433,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "cpu": [ "x64" ], @@ -10342,9 +10454,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", "cpu": [ "arm" ], @@ -10363,9 +10475,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", "cpu": [ "arm64" ], @@ -10384,9 +10496,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", "cpu": [ "arm64" ], @@ -10405,9 +10517,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", "cpu": [ "x64" ], @@ -10426,9 +10538,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", "cpu": [ "x64" ], @@ -10447,9 +10559,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "cpu": [ "arm64" ], @@ -10468,9 +10580,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "cpu": [ "x64" ], @@ -10564,9 +10676,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10716,29 +10828,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10746,9 +10835,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -10757,16 +10846,16 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -10838,6 +10927,24 @@ } } }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -11098,9 +11205,9 @@ } }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -11117,9 +11224,9 @@ } }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -11230,6 +11337,12 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/point-in-polygon-hao/node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/polyclip-ts": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", @@ -11254,6 +11367,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11278,6 +11392,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -11423,15 +11556,15 @@ } }, "node_modules/prisma": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz", - "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz", + "integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.16.2", - "@prisma/engines": "6.16.2" + "@prisma/config": "6.18.0", + "@prisma/engines": "6.18.0" }, "bin": { "prisma": "build/index.js" @@ -11476,6 +11609,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -11646,10 +11786,16 @@ "react": "^19.1.0" } }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/react-hook-form": { - "version": "7.63.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", - "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", + "version": "7.66.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", + "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -11680,10 +11826,11 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT", + "peer": true }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -11714,35 +11861,6 @@ "react": "^19.0.0" } }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-remove-scroll": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", @@ -11837,10 +11955,20 @@ } } }, + "node_modules/react-webcam": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-webcam/-/react-webcam-7.2.0.tgz", + "integrity": "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.2.0", + "react-dom": ">=16.2.0" + } + }, "node_modules/react-window": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.1.1.tgz", - "integrity": "sha512-Wx5yHri8G1nFxImnJRkEEKtRTnG3cWaqknUJyYvgisQtl1mw/d8LQmLXfuKxpn2dY8IwDn5mCIuxm2NVyIvgVQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.2.tgz", + "integrity": "sha512-kvHKwFImKBWNbx2S87NZOhQhAVkBthjmnOfHlhQI45p3A+D+V53E+CqQMsyHrxNe3ke+YtWXuKDa1eoHAaIWJg==", "license": "MIT", "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -11880,12 +12008,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -11901,9 +12023,9 @@ } }, "node_modules/recharts": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", - "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz", + "integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", @@ -11927,13 +12049,62 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/redux": { + "node_modules/recharts/node_modules/@reduxjs/toolkit": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz", + "integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, - "node_modules/redux-thunk": { + "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", @@ -11942,6 +12113,15 @@ "redux": "^5.0.0" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12009,13 +12189,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -12071,17 +12251,11 @@ } }, "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", "license": "Unlicense" }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12132,6 +12306,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -12155,6 +12336,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -12198,15 +12386,15 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "devOptional": true, "license": "ISC", "bin": { @@ -12357,6 +12545,12 @@ "node": ">=0.8" } }, + "node_modules/sheetjs-style/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -12797,16 +12991,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", - "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { @@ -12817,23 +13011,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -12993,6 +13170,12 @@ "topoquantize": "bin/topoquantize" } }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/topojson-server": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", @@ -13005,6 +13188,12 @@ "geo2topo": "bin/geo2topo" } }, + "node_modules/topojson-server/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -13072,6 +13261,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -13100,6 +13298,34 @@ "zustand": "^4.3.2" } }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -13202,9 +13428,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13336,9 +13562,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13532,6 +13758,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/which-collection": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", @@ -13715,16 +13948,6 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13739,29 +13962,27 @@ } }, "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -13772,6 +13993,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/frontend/package.json b/frontend/package.json index fe7fc518..6d4f3369 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", + "@zxing/library": "^0.21.3", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -77,6 +78,7 @@ "react-hot-toast": "^2.6.0", "react-leaflet": "^5.0.0", "react-resizable-panels": "^3.0.6", + "react-webcam": "^7.2.0", "react-window": "^2.1.0", "reactflow": "^11.11.4", "recharts": "^3.2.1", From d64ca5a8c0f62345637e17dc8ef686eec1e6810f Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 11:41:20 +0900 Subject: [PATCH 69/76] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 282 ++++++++++++------ .../screen/InteractiveScreenViewer.tsx | 15 +- .../screen/InteractiveScreenViewerDynamic.tsx | 6 +- .../screen/RealtimePreviewDynamic.tsx | 29 +- frontend/components/screen/ScreenList.tsx | 35 ++- .../screen/widgets/types/ButtonWidget.tsx | 6 +- .../lib/registry/DynamicWebTypeRenderer.tsx | 16 +- 7 files changed, 256 insertions(+), 133 deletions(-) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index dac590d6..a0b12c7f 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ export default function ScreenViewPage() { // 🆕 현재 로그인한 사용자 정보 const { user, userName, companyCode } = useAuth(); - + // 🆕 모바일 환경 감지 const { isMobile } = useResponsive(); @@ -189,10 +189,10 @@ export default function ScreenViewPage() { if (loading) { return ( -
-
- -

화면을 불러오는 중...

+
+
+ +

화면을 불러오는 중...

); @@ -200,13 +200,13 @@ export default function ScreenViewPage() { if (error || !screen) { return ( -
-
-
+
+
+
⚠️
-

화면을 찾을 수 없습니다

-

{error || "요청하신 화면이 존재하지 않습니다."}

+

화면을 찾을 수 없습니다

+

{error || "요청하신 화면이 존재하지 않습니다."}

@@ -225,7 +225,7 @@ export default function ScreenViewPage() { {/* 절대 위치 기반 렌더링 */} {layout && layout.components.length > 0 ? (
!component.parentId); + // 버튼은 scale에 맞춰 위치만 조정하면 됨 (scale = 1.0이면 그대로, scale < 1.0이면 왼쪽으로) + // 하지만 x=0 컴포넌트는 width: 100%로 확장되므로, 그만큼 버튼을 오른쪽으로 이동 + const leftmostComponent = topLevelComponents.find((c) => c.position.x === 0); + let widthOffset = 0; + + if (leftmostComponent && containerWidth > 0) { + const originalWidth = leftmostComponent.size?.width || screenWidth; + const actualWidth = containerWidth / scale; + widthOffset = Math.max(0, actualWidth - originalWidth); + + console.log("📊 widthOffset 계산:", { + containerWidth, + scale, + screenWidth, + originalWidth, + actualWidth, + widthOffset, + leftmostType: leftmostComponent.type, + }); + } + const buttonGroups: Record = {}; const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); + + console.log( + "🔍 메뉴에서 발견된 전체 버튼:", + allButtons.map((b) => ({ + id: b.id, + label: b.label, + positionX: b.position.x, + positionY: b.position.y, + })), + ); topLevelComponents.forEach((component) => { const isButton = - component.type === "button" || (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)); + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); if (isButton) { const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as | FlowVisibilityConfig | undefined; - if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) { + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = true; + + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { if (!buttonGroups[flowConfig.groupId]) { buttonGroups[flowConfig.groupId] = []; } buttonGroups[flowConfig.groupId].push(component); processedButtonIds.add(component.id); } + // else: 모든 버튼을 개별 렌더링 } }); @@ -267,92 +316,121 @@ export default function ScreenViewPage() { return ( <> {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => ( - {}} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - setSelectedRowsData(selectedData); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + {regularComponents.map((component) => { + // 버튼인 경우 위치 조정 (테이블이 늘어난 만큼 오른쪽으로 이동) + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); - return ( - {}} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - setSelectedRowsData(selectedData); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ))} + const adjustedComponent = + isButton && widthOffset > 0 + ? { + ...component, + position: { + ...component.position, + x: component.position.x + widthOffset, + }, + } + : component; + + // 버튼일 경우 로그 출력 + if (isButton) { + console.log("🔘 버튼 위치 조정:", { + label: component.label, + originalX: component.position.x, + adjustedX: component.position.x + widthOffset, + widthOffset, + }); + } + + return ( + {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + setSelectedRowsData(selectedData); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || component.type === "container" || component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; + + return ( + {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + setSelectedRowsData(selectedData); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ); + })} + + ); + })} {/* 🆕 플로우 버튼 그룹들 */} {Object.entries(buttonGroups).map(([groupId, buttons]) => { @@ -372,6 +450,12 @@ export default function ScreenViewPage() { { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 }, ); + // 버튼 그룹 위치에도 widthOffset 적용 (테이블이 늘어난 만큼 오른쪽으로 이동) + const adjustedGroupPosition = { + ...groupPosition, + x: groupPosition.x + widthOffset, + }; + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 const direction = groupConfig.groupDirection || "horizontal"; const gap = groupConfig.groupGap ?? 8; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8a114aa4..45b263d6 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1633,24 +1633,19 @@ export const InteractiveScreenViewer: React.FC = ( } }; - return ( + return applyStyles( +
+ + + +
+ + +
+ +
+ + +
+ + {part.generationMethod === "auto" ? ( + onUpdate({ autoConfig })} + isPreview={isPreview} + /> + ) : ( + onUpdate({ manualConfig })} + isPreview={isPreview} + /> + )} +
+ + ); +}; diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx new file mode 100644 index 00000000..d318feb0 --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -0,0 +1,407 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Plus, Save, Edit2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; +import { NumberingRuleCard } from "./NumberingRuleCard"; +import { NumberingRulePreview } from "./NumberingRulePreview"; +import { + getNumberingRules, + createNumberingRule, + updateNumberingRule, + deleteNumberingRule, +} from "@/lib/api/numberingRule"; + +interface NumberingRuleDesignerProps { + initialConfig?: NumberingRuleConfig; + onSave?: (config: NumberingRuleConfig) => void; + onChange?: (config: NumberingRuleConfig) => void; + maxRules?: number; + isPreview?: boolean; + className?: string; +} + +export const NumberingRuleDesigner: React.FC = ({ + initialConfig, + onSave, + onChange, + maxRules = 6, + isPreview = false, + className = "", +}) => { + const [savedRules, setSavedRules] = useState([]); + const [selectedRuleId, setSelectedRuleId] = useState(null); + const [currentRule, setCurrentRule] = useState(null); + const [loading, setLoading] = useState(false); + const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); + const [rightTitle, setRightTitle] = useState("규칙 편집"); + const [editingLeftTitle, setEditingLeftTitle] = useState(false); + const [editingRightTitle, setEditingRightTitle] = useState(false); + + useEffect(() => { + loadRules(); + }, []); + + const loadRules = useCallback(async () => { + setLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setSavedRules(response.data); + } else { + toast.error(response.error || "규칙 목록을 불러올 수 없습니다"); + } + } catch (error: any) { + toast.error(`로딩 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (currentRule) { + onChange?.(currentRule); + } + }, [currentRule, onChange]); + + const handleAddPart = useCallback(() => { + if (!currentRule) return; + + if (currentRule.parts.length >= maxRules) { + toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`); + return; + } + + const newPart: NumberingRulePart = { + id: `part-${Date.now()}`, + order: currentRule.parts.length + 1, + partType: "prefix", + generationMethod: "auto", + autoConfig: { prefix: "CODE" }, + }; + + setCurrentRule((prev) => { + if (!prev) return null; + return { ...prev, parts: [...prev.parts, newPart] }; + }); + + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); + }, [currentRule, maxRules]); + + const handleUpdatePart = useCallback((partId: string, updates: Partial) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)), + }; + }); + }, []); + + const handleDeletePart = useCallback((partId: string) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts + .filter((part) => part.id !== partId) + .map((part, index) => ({ ...part, order: index + 1 })), + }; + }); + + toast.success("규칙이 삭제되었습니다"); + }, []); + + const handleSave = useCallback(async () => { + if (!currentRule) { + toast.error("저장할 규칙이 없습니다"); + return; + } + + if (currentRule.parts.length === 0) { + toast.error("최소 1개 이상의 규칙을 추가해주세요"); + return; + } + + setLoading(true); + try { + const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId); + + let response; + if (existing) { + response = await updateNumberingRule(currentRule.ruleId, currentRule); + } else { + response = await createNumberingRule(currentRule); + } + + if (response.success && response.data) { + setSavedRules((prev) => { + if (existing) { + return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r)); + } else { + return [...prev, response.data!]; + } + }); + + setCurrentRule(response.data); + setSelectedRuleId(response.data.ruleId); + + await onSave?.(response.data); + toast.success("채번 규칙이 저장되었습니다"); + } else { + toast.error(response.error || "저장 실패"); + } + } catch (error: any) { + toast.error(`저장 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, [currentRule, savedRules, onSave]); + + const handleSelectRule = useCallback((rule: NumberingRuleConfig) => { + setSelectedRuleId(rule.ruleId); + setCurrentRule(rule); + toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); + }, []); + + const handleDeleteSavedRule = useCallback(async (ruleId: string) => { + setLoading(true); + try { + const response = await deleteNumberingRule(ruleId); + + if (response.success) { + setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); + + if (selectedRuleId === ruleId) { + setSelectedRuleId(null); + setCurrentRule(null); + } + + toast.success("규칙이 삭제되었습니다"); + } else { + toast.error(response.error || "삭제 실패"); + } + } catch (error: any) { + toast.error(`삭제 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, [selectedRuleId]); + + const handleNewRule = useCallback(() => { + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }; + + setSelectedRuleId(newRule.ruleId); + setCurrentRule(newRule); + + toast.success("새 규칙이 생성되었습니다"); + }, []); + + return ( +
+ {/* 좌측: 저장된 규칙 목록 */} +
+
+ {editingLeftTitle ? ( + setLeftTitle(e.target.value)} + onBlur={() => setEditingLeftTitle(false)} + onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)} + className="h-8 text-sm font-semibold" + autoFocus + /> + ) : ( +

{leftTitle}

+ )} + +
+ + + +
+ {loading ? ( +
+

로딩 중...

+
+ ) : savedRules.length === 0 ? ( +
+

저장된 규칙이 없습니다

+
+ ) : ( + savedRules.map((rule) => ( + handleSelectRule(rule)} + > + +
+
+ {rule.ruleName} +

+ 규칙 {rule.parts.length}개 +

+
+ +
+
+ + + +
+ )) + )} +
+
+ + {/* 구분선 */} +
+ + {/* 우측: 편집 영역 */} +
+ {!currentRule ? ( +
+
+

+ 규칙을 선택해주세요 +

+

+ 좌측에서 규칙을 선택하거나 새로 생성하세요 +

+
+
+ ) : ( + <> +
+ {editingRightTitle ? ( + setRightTitle(e.target.value)} + onBlur={() => setEditingRightTitle(false)} + onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} + className="h-8 text-sm font-semibold" + autoFocus + /> + ) : ( +

{rightTitle}

+ )} + +
+ +
+ + + setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value })) + } + className="h-9" + placeholder="예: 프로젝트 코드" + /> +
+ + + + 미리보기 + + + + + + +
+
+

코드 구성

+ + {currentRule.parts.length}/{maxRules} + +
+ + {currentRule.parts.length === 0 ? ( +
+

+ 규칙을 추가하여 코드를 구성하세요 +

+
+ ) : ( +
+ {currentRule.parts.map((part) => ( + handleUpdatePart(part.id, updates)} + onDelete={() => handleDeletePart(part.id)} + isPreview={isPreview} + /> + ))} +
+ )} +
+ +
+ + +
+ + )} +
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx new file mode 100644 index 00000000..38e9dbfd --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -0,0 +1,97 @@ +"use client"; + +import React, { useMemo } from "react"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +interface NumberingRulePreviewProps { + config: NumberingRuleConfig; + compact?: boolean; +} + +export const NumberingRulePreview: React.FC = ({ + config, + compact = false +}) => { + const generatedCode = useMemo(() => { + if (!config.parts || config.parts.length === 0) { + return "규칙을 추가해주세요"; + } + + const parts = config.parts + .sort((a, b) => a.order - b.order) + .map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "prefix": + return autoConfig.prefix || "PREFIX"; + + case "sequence": { + const length = autoConfig.sequenceLength || 4; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); + } + + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } + } + + case "year": { + const now = new Date(); + const format = autoConfig.dateFormat || "YYYY"; + return format === "YY" + ? String(now.getFullYear()).slice(-2) + : String(now.getFullYear()); + } + + case "month": { + const now = new Date(); + return String(now.getMonth() + 1).padStart(2, "0"); + } + + case "custom": + return autoConfig.value || "CUSTOM"; + + default: + return "XXX"; + } + }); + + return parts.join(config.separator || ""); + }, [config]); + + if (compact) { + return ( +
+ {generatedCode} +
+ ); + } + + return ( +
+

코드 미리보기

+
+ {generatedCode} +
+
+ ); +}; diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts new file mode 100644 index 00000000..7702ea08 --- /dev/null +++ b/frontend/lib/api/numberingRule.ts @@ -0,0 +1,81 @@ +/** + * 채번 규칙 관리 API 클라이언트 + */ + +import { apiClient } from "./client"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export async function getNumberingRules(): Promise> { + try { + const response = await apiClient.get("/numbering-rules"); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 목록 조회 실패" }; + } +} + +export async function getNumberingRuleById(ruleId: string): Promise> { + try { + const response = await apiClient.get(`/numbering-rules/${ruleId}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 조회 실패" }; + } +} + +export async function createNumberingRule( + config: NumberingRuleConfig +): Promise> { + try { + const response = await apiClient.post("/numbering-rules", config); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 생성 실패" }; + } +} + +export async function updateNumberingRule( + ruleId: string, + config: Partial +): Promise> { + try { + const response = await apiClient.put(`/numbering-rules/${ruleId}`, config); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 수정 실패" }; + } +} + +export async function deleteNumberingRule(ruleId: string): Promise> { + try { + const response = await apiClient.delete(`/numbering-rules/${ruleId}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 삭제 실패" }; + } +} + +export async function generateCode(ruleId: string): Promise> { + try { + const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "코드 생성 실패" }; + } +} + +export async function resetSequence(ruleId: string): Promise> { + try { + const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "시퀀스 초기화 실패" }; + } +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index f2ac68c2..315cf1da 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -39,6 +39,7 @@ import "./split-panel-layout/SplitPanelLayoutRenderer"; import "./map/MapRenderer"; import "./repeater-field-group/RepeaterFieldGroupRenderer"; import "./flow-widget/FlowWidgetRenderer"; +import "./numbering-rule/NumberingRuleRenderer"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx new file mode 100644 index 00000000..78c366fd --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; +import { NumberingRuleComponentConfig } from "./types"; + +interface NumberingRuleWrapperProps { + config: NumberingRuleComponentConfig; + onChange?: (config: NumberingRuleComponentConfig) => void; + isPreview?: boolean; +} + +export const NumberingRuleWrapper: React.FC = ({ + config, + onChange, + isPreview = false, +}) => { + return ( +
+ +
+ ); +}; + +export const NumberingRuleComponent = NumberingRuleWrapper; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx new file mode 100644 index 00000000..332d4055 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { NumberingRuleComponentConfig } from "./types"; + +interface NumberingRuleConfigPanelProps { + config: NumberingRuleComponentConfig; + onChange: (config: NumberingRuleComponentConfig) => void; +} + +export const NumberingRuleConfigPanel: React.FC = ({ + config, + onChange, +}) => { + return ( +
+
+ + + onChange({ ...config, maxRules: parseInt(e.target.value) || 6 }) + } + className="h-9" + /> +

+ 한 규칙에 추가할 수 있는 최대 파트 개수 (1-10) +

+
+ +
+
+ +

+ 편집 기능을 비활성화합니다 +

+
+ + onChange({ ...config, readonly: checked }) + } + /> +
+ +
+
+ +

+ 코드 미리보기를 항상 표시합니다 +

+
+ + onChange({ ...config, showPreview: checked }) + } + /> +
+ +
+
+ +

+ 저장된 규칙 목록을 표시합니다 +

+
+ + onChange({ ...config, showRuleList: checked }) + } + /> +
+ +
+ + +

+ 규칙 파트 카드의 배치 방향 +

+
+
+ ); +}; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx new file mode 100644 index 00000000..29c98b45 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { NumberingRuleDefinition } from "./index"; +import { NumberingRuleComponent } from "./NumberingRuleComponent"; + +/** + * 채번 규칙 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = NumberingRuleDefinition; + + render(): React.ReactElement { + return ; + } + + /** + * 채번 규칙 컴포넌트 특화 메서드 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +NumberingRuleRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + NumberingRuleRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/numbering-rule/README.md b/frontend/lib/registry/components/numbering-rule/README.md new file mode 100644 index 00000000..5d04d894 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/README.md @@ -0,0 +1,102 @@ +# 코드 채번 규칙 컴포넌트 + +## 개요 + +시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. + +## 주요 기능 + +- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집 +- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합 +- **실시간 미리보기**: 설정 즉시 생성될 코드 확인 +- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀 + +## 생성 코드 예시 + +- 제품 코드: `PROD-20251104-0001` +- 프로젝트 코드: `PRJ-2025-001` +- 거래처 코드: `CUST-A-0001` + +## 파트 유형 + +### 1. 접두사 (prefix) +고정된 문자열을 코드 앞에 추가합니다. +- 예: `PROD`, `PRJ`, `CUST` + +### 2. 순번 (sequence) +자동으로 증가하는 번호를 생성합니다. +- 자릿수 설정 가능 (1-10) +- 시작 번호 설정 가능 +- 예: `0001`, `00001` + +### 3. 날짜 (date) +현재 날짜를 다양한 형식으로 추가합니다. +- YYYY: 2025 +- YYYYMMDD: 20251104 +- YYMMDD: 251104 + +### 4. 연도 (year) +현재 연도를 추가합니다. +- YYYY: 2025 +- YY: 25 + +### 5. 월 (month) +현재 월을 2자리로 추가합니다. +- 예: 01, 02, ..., 12 + +### 6. 사용자 정의 (custom) +원하는 값을 직접 입력합니다. + +## 생성 방식 + +### 자동 생성 (auto) +시스템이 자동으로 값을 생성합니다. + +### 직접 입력 (manual) +사용자가 값을 직접 입력합니다. + +## 설정 옵션 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `maxRules` | number | 6 | 최대 파트 개수 | +| `readonly` | boolean | false | 읽기 전용 모드 | +| `showPreview` | boolean | true | 미리보기 표시 | +| `showRuleList` | boolean | true | 규칙 목록 표시 | +| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 | + +## 사용 예시 + +```typescript + +``` + +## 데이터베이스 구조 + +### numbering_rules (마스터 테이블) +- 규칙 ID, 규칙명, 구분자 +- 초기화 주기, 현재 시퀀스 +- 적용 대상 테이블/컬럼 + +### numbering_rule_parts (파트 테이블) +- 파트 순서, 파트 유형 +- 생성 방식, 설정 (JSONB) + +## API 엔드포인트 + +- `GET /api/numbering-rules` - 규칙 목록 조회 +- `POST /api/numbering-rules` - 규칙 생성 +- `PUT /api/numbering-rules/:ruleId` - 규칙 수정 +- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 +- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 + +## 버전 정보 + +- **버전**: 1.0.0 +- **작성일**: 2025-11-04 +- **작성자**: 개발팀 + diff --git a/frontend/lib/registry/components/numbering-rule/config.ts b/frontend/lib/registry/components/numbering-rule/config.ts new file mode 100644 index 00000000..87e5c996 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/config.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 기본 설정 + */ + +import { NumberingRuleComponentConfig } from "./types"; + +export const defaultConfig: NumberingRuleComponentConfig = { + maxRules: 6, + readonly: false, + showPreview: true, + showRuleList: true, + enableReorder: false, + cardLayout: "vertical", +}; + diff --git a/frontend/lib/registry/components/numbering-rule/index.ts b/frontend/lib/registry/components/numbering-rule/index.ts new file mode 100644 index 00000000..6399ab2a --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/index.ts @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { NumberingRuleWrapper } from "./NumberingRuleComponent"; +import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel"; +import { defaultConfig } from "./config"; + +/** + * 채번 규칙 컴포넌트 정의 + * 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트 + */ +export const NumberingRuleDefinition = createComponentDefinition({ + id: "numbering-rule", + name: "코드 채번 규칙", + nameEng: "Numbering Rule Component", + description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "component", + component: NumberingRuleWrapper, + defaultConfig: defaultConfig, + defaultSize: { + width: 1200, + height: 800, + gridColumnSpan: "12", + }, + configPanel: NumberingRuleConfigPanel, + icon: "Hash", + tags: ["코드", "채번", "규칙", "표시", "자동생성"], + version: "1.0.0", + author: "개발팀", + documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.", +}); + +// 타입 내보내기 +export type { NumberingRuleComponentConfig } from "./types"; + +// 컴포넌트 내보내기 +export { NumberingRuleComponent } from "./NumberingRuleComponent"; +export { NumberingRuleRenderer } from "./NumberingRuleRenderer"; + diff --git a/frontend/lib/registry/components/numbering-rule/types.ts b/frontend/lib/registry/components/numbering-rule/types.ts new file mode 100644 index 00000000..43def2cb --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/types.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 타입 정의 + */ + +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface NumberingRuleComponentConfig { + ruleConfig?: NumberingRuleConfig; + maxRules?: number; + readonly?: boolean; + showPreview?: boolean; + showRuleList?: boolean; + enableReorder?: boolean; + cardLayout?: "vertical" | "horizontal"; +} diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts new file mode 100644 index 00000000..dbdbf9bd --- /dev/null +++ b/frontend/types/numbering-rule.ts @@ -0,0 +1,117 @@ +/** + * 코드 채번 규칙 컴포넌트 타입 정의 + * Shadcn/ui 가이드라인 기반 + */ + +/** + * 코드 파트 유형 + */ +export type CodePartType = + | "prefix" // 접두사 (고정 문자열) + | "sequence" // 순번 (자동 증가) + | "date" // 날짜 (YYYYMMDD 등) + | "year" // 연도 (YYYY) + | "month" // 월 (MM) + | "custom"; // 사용자 정의 + +/** + * 생성 방식 + */ +export type GenerationMethod = + | "auto" // 자동 생성 + | "manual"; // 직접 입력 + +/** + * 날짜 형식 + */ +export type DateFormat = + | "YYYY" // 2025 + | "YY" // 25 + | "YYYYMM" // 202511 + | "YYMM" // 2511 + | "YYYYMMDD" // 20251104 + | "YYMMDD"; // 251104 + +/** + * 단일 규칙 파트 + */ +export interface NumberingRulePart { + id: string; // 고유 ID + order: number; // 순서 (1-6) + partType: CodePartType; // 파트 유형 + generationMethod: GenerationMethod; // 생성 방식 + + // 자동 생성 설정 + autoConfig?: { + prefix?: string; // 접두사 + sequenceLength?: number; // 순번 자릿수 + startFrom?: number; // 시작 번호 + dateFormat?: DateFormat; // 날짜 형식 + value?: string; // 커스텀 값 + }; + + // 직접 입력 설정 + manualConfig?: { + value: string; // 입력값 + placeholder?: string; // 플레이스홀더 + }; + + // 생성된 값 (미리보기용) + generatedValue?: string; +} + +/** + * 전체 채번 규칙 + */ +export interface NumberingRuleConfig { + ruleId: string; // 규칙 ID + ruleName: string; // 규칙명 + description?: string; // 설명 + parts: NumberingRulePart[]; // 규칙 파트 배열 + + // 설정 + separator?: string; // 구분자 (기본: "-") + resetPeriod?: "none" | "daily" | "monthly" | "yearly"; + currentSequence?: number; // 현재 시퀀스 + + // 적용 대상 + tableName?: string; // 적용할 테이블명 + columnName?: string; // 적용할 컬럼명 + + // 메타 정보 + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +/** + * UI 옵션 상수 + */ +export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string }> = [ + { value: "prefix", label: "접두사" }, + { value: "sequence", label: "순번" }, + { value: "date", label: "날짜" }, + { value: "year", label: "연도" }, + { value: "month", label: "월" }, + { value: "custom", label: "사용자 정의" }, +]; + +export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ + { value: "YYYY", label: "연도 (4자리)", example: "2025" }, + { value: "YY", label: "연도 (2자리)", example: "25" }, + { value: "YYYYMM", label: "연도+월", example: "202511" }, + { value: "YYMM", label: "연도(2)+월", example: "2511" }, + { value: "YYYYMMDD", label: "연월일", example: "20251104" }, + { value: "YYMMDD", label: "연(2)+월일", example: "251104" }, +]; + +export const RESET_PERIOD_OPTIONS: Array<{ + value: "none" | "daily" | "monthly" | "yearly"; + label: string; +}> = [ + { value: "none", label: "초기화 안함" }, + { value: "daily", label: "일별 초기화" }, + { value: "monthly", label: "월별 초기화" }, + { value: "yearly", label: "연별 초기화" }, +]; From 39080dff592e423a8f18a4f272288285f7208307 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 14:33:39 +0900 Subject: [PATCH 73/76] =?UTF-8?q?autofill=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 127 ++++++- .../src/routes/tableManagementRoutes.ts | 7 + .../app/(main)/screens/[screenId]/page.tsx | 51 +++ .../screen/InteractiveDataTable.tsx | 3 +- .../screen/InteractiveScreenViewer.tsx | 46 ++- .../screen/InteractiveScreenViewerDynamic.tsx | 59 +++ .../screen/panels/DataTableConfigPanel.tsx | 84 +++++ .../screen/panels/DetailSettingsPanel.tsx | 275 +++++++++++++- .../screen/panels/UnifiedPropertiesPanel.tsx | 335 ++++++++++++++++-- frontend/lib/api/screen.ts | 15 + frontend/types/screen-management.ts | 16 + 11 files changed, 966 insertions(+), 52 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index d7b2bd74..9661ab0a 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -12,6 +12,7 @@ import { ColumnListResponse, ColumnSettingsResponse, } from "../types/tableManagement"; +import { query } from "../database/db"; // 🆕 query 함수 import /** * 테이블 목록 조회 @@ -506,7 +507,91 @@ export async function updateColumnInputType( } /** - * 테이블 데이터 조회 (페이징 + 검색) + * 단일 레코드 조회 (자동 입력용) + */ +export async function getTableRecord( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { filterColumn, filterValue, displayColumn } = req.body; + + logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`); + logger.info(`필터: ${filterColumn} = ${filterValue}`); + logger.info(`표시 컬럼: ${displayColumn}`); + + if (!tableName || !filterColumn || !filterValue || !displayColumn) { + const response: ApiResponse = { + success: false, + message: "필수 파라미터가 누락되었습니다.", + error: { + code: "MISSING_PARAMETERS", + details: + "tableName, filterColumn, filterValue, displayColumn이 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 단일 레코드 조회 (WHERE filterColumn = filterValue) + const result = await tableManagementService.getTableData(tableName, { + page: 1, + size: 1, + search: { + [filterColumn]: filterValue, + }, + }); + + if (!result.data || result.data.length === 0) { + const response: ApiResponse = { + success: false, + message: "데이터를 찾을 수 없습니다.", + error: { + code: "NOT_FOUND", + details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`, + }, + }; + res.status(404).json(response); + return; + } + + const record = result.data[0]; + const displayValue = record[displayColumn]; + + logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`); + + const response: ApiResponse<{ value: any; record: any }> = { + success: true, + message: "레코드를 성공적으로 조회했습니다.", + data: { + value: displayValue, + record: record, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("레코드 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: { + code: "RECORD_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 데이터 조회 (페이징 + 검색 + 필터링) */ export async function getTableData( req: AuthenticatedRequest, @@ -520,12 +605,14 @@ export async function getTableData( search = {}, sortBy, sortOrder = "asc", + autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) } = req.body; logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`페이징: page=${page}, size=${size}`); logger.info(`검색 조건:`, search); logger.info(`정렬: ${sortBy} ${sortOrder}`); + logger.info(`자동 필터:`, autoFilter); // 🆕 if (!tableName) { const response: ApiResponse = { @@ -542,11 +629,35 @@ export async function getTableData( const tableManagementService = new TableManagementService(); + // 🆕 현재 사용자 필터 적용 + let enhancedSearch = { ...search }; + if (autoFilter?.enabled && req.user) { + const filterColumn = autoFilter.filterColumn || "company_code"; + const userField = autoFilter.userField || "companyCode"; + const userValue = (req.user as any)[userField]; + + if (userValue) { + enhancedSearch[filterColumn] = userValue; + + logger.info("🔍 현재 사용자 필터 적용:", { + filterColumn, + userField, + userValue, + tableName, + }); + } else { + logger.warn("⚠️ 사용자 정보 필드 값 없음:", { + userField, + user: req.user, + }); + } + } + // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), size: parseInt(size), - search, + search: enhancedSearch, // 🆕 필터가 적용된 search 사용 sortBy, sortOrder, }); @@ -1216,9 +1327,7 @@ export async function getLogData( originalId: originalId as string, }); - logger.info( - `로그 데이터 조회 완료: ${tableName}_log, ${result.total}건` - ); + logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`); const response: ApiResponse = { success: true, @@ -1254,7 +1363,9 @@ export async function toggleLogTable( const { tableName } = req.params; const { isActive } = req.body; - logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); + logger.info( + `=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===` + ); if (!tableName) { const response: ApiResponse = { @@ -1288,9 +1399,7 @@ export async function toggleLogTable( isActive === "Y" || isActive === true ); - logger.info( - `로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}` - ); + logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`); const response: ApiResponse = { success: true, diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5e5ddf38..9840c9c4 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -11,6 +11,7 @@ import { updateColumnInputType, updateTableLabel, getTableData, + getTableRecord, // 🆕 단일 레코드 조회 addTableData, editTableData, deleteTableData, @@ -134,6 +135,12 @@ router.get("/health", checkDatabaseConnection); */ router.post("/tables/:tableName/data", getTableData); +/** + * 단일 레코드 조회 (자동 입력용) + * POST /api/table-management/tables/:tableName/record + */ +router.post("/tables/:tableName/record", getTableRecord); + /** * 테이블 데이터 추가 * POST /api/table-management/tables/:tableName/add diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index dac590d6..c5f35351 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -147,6 +147,57 @@ export default function ScreenViewPage() { } }, [screenId]); + // 🆕 autoFill 자동 입력 초기화 + useEffect(() => { + const initAutoFill = async () => { + if (!layout || !layout.components || !user) { + return; + } + + for (const comp of layout.components) { + // type: "component" 또는 type: "widget" 모두 처리 + if (comp.type === 'widget' || comp.type === 'component') { + const widget = comp as any; + const fieldName = widget.columnName || widget.id; + + // autoFill 처리 + if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { + const autoFillConfig = widget.autoFill || (comp as any).autoFill; + const currentValue = formData[fieldName]; + + if (currentValue === undefined || currentValue === '') { + const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; + + // 사용자 정보에서 필터 값 가져오기 + const userValue = user?.[userField as keyof typeof user]; + + if (userValue && sourceTable && filterColumn && displayColumn) { + try { + const { tableTypeApi } = await import("@/lib/api/screen"); + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + + setFormData((prev) => ({ + ...prev, + [fieldName]: result.value, + })); + } catch (error) { + console.error(`autoFill 조회 실패: ${fieldName}`, error); + } + } + } + } + } + } + }; + + initAutoFill(); + }, [layout, user]); + // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화 useEffect(() => { // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index e05cc973..f4123169 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -485,6 +485,7 @@ export const InteractiveDataTable: React.FC = ({ page, size: pageSize, search: searchParams, + autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달 }); setData(result.data); @@ -576,7 +577,7 @@ export const InteractiveDataTable: React.FC = ({ setLoading(false); } }, - [component.tableName, pageSize], + [component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가 ); // 현재 사용자 정보 로드 diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 572aab37..f1e47a5f 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -36,7 +36,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable"; import { FileUpload } from "./widgets/FileUpload"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { useParams } from "next/navigation"; -import { screenApi } from "@/lib/api/screen"; +import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer"; import { enhancedFormService } from "@/lib/services/enhancedFormService"; import { FormValidationIndicator } from "@/components/common/FormValidationIndicator"; @@ -237,14 +237,46 @@ export const InteractiveScreenViewer: React.FC = ( // 자동입력 필드들의 값을 formData에 초기 설정 React.useEffect(() => { // console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length); - const initAutoInputFields = () => { + const initAutoInputFields = async () => { // console.log("🔧 initAutoInputFields 실행 시작"); - allComponents.forEach(comp => { - if (comp.type === 'widget') { + for (const comp of allComponents) { + // 🆕 type: "component" 또는 type: "widget" 모두 처리 + if (comp.type === 'widget' || comp.type === 'component') { const widget = comp as WidgetComponent; const fieldName = widget.columnName || widget.id; - // 텍스트 타입 위젯의 자동입력 처리 + // 🆕 autoFill 처리 (테이블 조회 기반 자동 입력) + if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { + const autoFillConfig = widget.autoFill || (comp as any).autoFill; + const currentValue = formData[fieldName]; + if (currentValue === undefined || currentValue === '') { + const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; + + // 사용자 정보에서 필터 값 가져오기 + const userValue = user?.[userField]; + + if (userValue && sourceTable && filterColumn && displayColumn) { + try { + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + + updateFormData(fieldName, result.value); + } catch (error) { + console.error(`autoFill 조회 실패: ${fieldName}`, error); + } + } + } + continue; // autoFill이 활성화되면 일반 자동입력은 건너뜀 + } + + // 기존 widget 타입 전용 로직은 widget인 경우만 + if (comp.type !== 'widget') continue; + + // 텍스트 타입 위젯의 자동입력 처리 (기존 로직) if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && widget.webTypeConfig) { const config = widget.webTypeConfig as TextTypeConfig; @@ -278,12 +310,12 @@ export const InteractiveScreenViewer: React.FC = ( } } } - }); + } }; // 초기 로드 시 자동입력 필드들 설정 initAutoInputFields(); - }, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지) + }, [allComponents, generateAutoValue, user]); // formData는 의존성에서 제외 (무한 루프 방지) // 날짜 값 업데이트 const updateDateValue = (fieldName: string, date: Date | undefined) => { diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index baa4ce83..9eb7cea1 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -81,6 +81,21 @@ export const InteractiveScreenViewerDynamic: React.FC { + if (onFormDataChange) { + onFormDataChange(fieldName, value); + } else { + setLocalFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + } + }, + [onFormDataChange], + ); + // 자동값 생성 함수 const generateAutoValue = useCallback( (autoValueType: string): string => { @@ -105,6 +120,50 @@ export const InteractiveScreenViewerDynamic: React.FC { + const initAutoInputFields = async () => { + for (const comp of allComponents) { + // type: "component" 또는 type: "widget" 모두 처리 + if (comp.type === 'widget' || comp.type === 'component') { + const widget = comp as any; + const fieldName = widget.columnName || widget.id; + + // autoFill 처리 (테이블 조회 기반 자동 입력) + if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { + const autoFillConfig = widget.autoFill || (comp as any).autoFill; + const currentValue = formData[fieldName]; + + if (currentValue === undefined || currentValue === '') { + const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; + + // 사용자 정보에서 필터 값 가져오기 + const userValue = user?.[userField]; + + if (userValue && sourceTable && filterColumn && displayColumn) { + try { + const { tableTypeApi } = await import("@/lib/api/screen"); + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + + updateFormData(fieldName, result.value); + } catch (error) { + console.error(`autoFill 조회 실패: ${fieldName}`, error); + } + } + } + } + } + } + }; + + initAutoInputFields(); + }, [allComponents, user]); + // 팝업 화면 레이아웃 로드 React.useEffect(() => { if (popupScreen?.screenId) { diff --git a/frontend/components/screen/panels/DataTableConfigPanel.tsx b/frontend/components/screen/panels/DataTableConfigPanel.tsx index 8cf02cbc..e92f0191 100644 --- a/frontend/components/screen/panels/DataTableConfigPanel.tsx +++ b/frontend/components/screen/panels/DataTableConfigPanel.tsx @@ -2198,6 +2198,90 @@ const DataTableConfigPanelComponent: React.FC = ({ + {/* 🆕 자동 필터 설정 */} +
+

+ + 현재 사용자 정보로 필터링 +

+
+
+ { + onUpdateComponent({ + autoFilter: { + enabled: checked as boolean, + filterColumn: component.autoFilter?.filterColumn || 'company_code', + userField: component.autoFilter?.userField || 'companyCode', + }, + }); + }} + /> + +
+ + {component.autoFilter?.enabled && ( +
+
+ + { + onUpdateComponent({ + autoFilter: { + ...component.autoFilter!, + filterColumn: e.target.value, + }, + }); + }} + placeholder="company_code" + className="text-xs" + /> +

+ 예: company_code, dept_code, user_id +

+
+ +
+ + +

+ 로그인한 사용자 정보에서 가져올 필드 +

+
+
+ )} +
+
+ {/* 페이지네이션 설정 */}

페이지네이션 설정

diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 14c7e388..47e1b102 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -1,8 +1,12 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Settings } from "lucide-react"; +import { Settings, Database } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent"; import { @@ -1125,6 +1129,136 @@ export const DetailSettingsPanel: React.FC = ({ }); }} /> + + {/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */} +
+

+ + 테이블 데이터 자동 입력 +

+
+
+ { + onUpdateProperty(selectedComponent.id, "autoFill", { + enabled: checked as boolean, + sourceTable: selectedComponent.autoFill?.sourceTable || "", + filterColumn: selectedComponent.autoFill?.filterColumn || "company_code", + userField: selectedComponent.autoFill?.userField || "companyCode", + displayColumn: selectedComponent.autoFill?.displayColumn || "", + }); + }} + /> + +
+ + {selectedComponent.autoFill?.enabled && ( +
+
+ + +

데이터를 조회할 테이블

+
+ +
+ + { + onUpdateProperty(selectedComponent.id, "autoFill", { + ...selectedComponent.autoFill!, + filterColumn: e.target.value, + }); + }} + placeholder="company_code" + className="text-xs" + /> +

예: company_code, dept_code, user_id

+
+ +
+ + +

로그인한 사용자의 정보를 필터로 사용

+
+ +
+ + { + onUpdateProperty(selectedComponent.id, "autoFill", { + ...selectedComponent.autoFill!, + displayColumn: e.target.value, + }); + }} + placeholder="company_name" + className="text-xs" + /> +

+ 조회된 레코드에서 표시할 컬럼 (예: company_name) +

+
+
+ )} +
+
@@ -1202,7 +1336,144 @@ export const DetailSettingsPanel: React.FC = ({
{/* 상세 설정 영역 */} -
{renderWebTypeConfig(widget)}
+
+
+ {console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)} + {/* 🆕 자동 입력 섹션 */} +
+

+ + 🔥 테이블 데이터 자동 입력 (테스트) +

+
+
+ { + onUpdateProperty(widget.id, "autoFill", { + enabled: checked as boolean, + sourceTable: widget.autoFill?.sourceTable || '', + filterColumn: widget.autoFill?.filterColumn || 'company_code', + userField: widget.autoFill?.userField || 'companyCode', + displayColumn: widget.autoFill?.displayColumn || '', + }); + }} + /> + +
+ + {widget.autoFill?.enabled && ( +
+
+ + +

+ 데이터를 조회할 테이블 +

+
+ +
+ + { + onUpdateProperty(widget.id, "autoFill", { + ...widget.autoFill!, + filterColumn: e.target.value, + }); + }} + placeholder="company_code" + className="text-xs" + /> +

+ 예: company_code, dept_code, user_id +

+
+ +
+ + +

+ 로그인한 사용자 정보에서 가져올 필드 +

+
+ +
+ + { + onUpdateProperty(widget.id, "autoFill", { + ...widget.autoFill!, + displayColumn: e.target.value, + }); + }} + placeholder="company_name" + className="text-xs" + /> +

+ Input에 표시할 컬럼명 (예: company_name, dept_name) +

+
+
+ )} +
+
+ + {/* 웹타입 설정 */} + + {renderWebTypeConfig(widget)} +
+
); }; diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index b432ef8b..12007094 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -118,9 +118,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ {/* 안내 메시지 */}
- -

컴포넌트를 선택하여

-

속성을 편집하세요

+ +

컴포넌트를 선택하여

+

속성을 편집하세요

@@ -412,8 +412,11 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합) const renderDetailTab = () => { + console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type); + // 1. DataTable 컴포넌트 if (selectedComponent.type === "datatable") { + console.log("✅ [renderDetailTab] DataTable 컴포넌트"); return ( = ({ // 5. 새로운 컴포넌트 시스템 (type: "component") if (selectedComponent.type === "component") { + console.log("✅ [renderDetailTab] Component 타입"); const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type; const webType = selectedComponent.componentConfig?.webType; @@ -479,7 +483,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ if (!componentId) { return (
-

컴포넌트 ID가 설정되지 않았습니다

+

컴포넌트 ID가 설정되지 않았습니다

); } @@ -511,7 +515,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{option.label}
-
{option.description}
+
{option.description}
))} @@ -535,45 +539,154 @@ export const UnifiedPropertiesPanel: React.FC = ({ }); }} /> + + {/* 🆕 테이블 데이터 자동 입력 (component 타입용) */} + +
+
+ +

테이블 데이터 자동 입력

+
+ + {/* 활성화 체크박스 */} +
+ { + handleUpdate("autoFill", { + ...selectedComponent.autoFill, + enabled: Boolean(checked), + }); + }} + /> + +
+ + {selectedComponent.autoFill?.enabled && ( + <> + {/* 조회할 테이블 */} +
+ + +
+ + {/* 필터링할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...selectedComponent.autoFill, + enabled: selectedComponent.autoFill?.enabled || false, + filterColumn: e.target.value, + }); + }} + placeholder="예: company_code" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + {/* 사용자 정보 필드 */} +
+ + +
+ + {/* 표시할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...selectedComponent.autoFill, + enabled: selectedComponent.autoFill?.enabled || false, + displayColumn: e.target.value, + }); + }} + placeholder="예: company_name" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + )} +
); } // 6. Widget 컴포넌트 if (selectedComponent.type === "widget") { + console.log("✅ [renderDetailTab] Widget 타입"); const widget = selectedComponent as WidgetComponent; + console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType); - // Widget에 webType이 있는 경우 - if (widget.webType) { - return ( -
- {/* WebType 선택 */} -
- - -
-
- ); - } - - // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) + // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크 if ( widget.widgetType && ["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes( widget.widgetType, ) ) { + console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)"); return ( = ({ /> ); } + + // 일반 위젯 (webType 기반) + console.log("✅ [renderDetailTab] 일반 위젯 렌더링 시작"); + return ( +
+ {console.log("🔍 [UnifiedPropertiesPanel] widget.webType:", widget.webType, "widget:", widget)} + {/* WebType 선택 (있는 경우만) */} + {widget.webType && ( +
+ + +
+ )} + + {/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */} + +
+
+ +

테이블 데이터 자동 입력

+
+ + {/* 활성화 체크박스 */} +
+ { + handleUpdate("autoFill", { + ...widget.autoFill, + enabled: Boolean(checked), + }); + }} + /> + +
+ + {widget.autoFill?.enabled && ( + <> + {/* 조회할 테이블 */} +
+ + +
+ + {/* 필터링할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...widget.autoFill, + enabled: widget.autoFill?.enabled || false, + filterColumn: e.target.value, + }); + }} + placeholder="예: company_code" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + {/* 사용자 정보 필드 */} +
+ + +
+ + {/* 표시할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...widget.autoFill, + enabled: widget.autoFill?.enabled || false, + displayColumn: e.target.value, + }); + }} + placeholder="예: company_name" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + )} +
+
+ ); } // 기본 메시지 return (
-

이 컴포넌트는 추가 설정이 없습니다

+

이 컴포넌트는 추가 설정이 없습니다

); }; @@ -602,9 +871,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ return (
{/* 헤더 - 간소화 */} -
+
{selectedComponent.type === "widget" && ( -
+
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
)} diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 3c885a8b..a37fbdff 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -313,6 +313,21 @@ export const tableTypeApi = { deleteTableData: async (tableName: string, data: Record[] | { ids: string[] }): Promise => { await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data }); }, + + // 🆕 단일 레코드 조회 (자동 입력용) + getTableRecord: async ( + tableName: string, + filterColumn: string, + filterValue: any, + displayColumn: string, + ): Promise<{ value: any; record: Record }> => { + const response = await apiClient.post(`/table-management/tables/${tableName}/record`, { + filterColumn, + filterValue, + displayColumn, + }); + return response.data.data; + }, }; // 메뉴-화면 할당 관련 API diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index b2eff0f1..56a9ba2d 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -84,6 +84,15 @@ export interface WidgetComponent extends BaseComponent { entityConfig?: EntityTypeConfig; buttonConfig?: ButtonTypeConfig; arrayConfig?: ArrayTypeConfig; + + // 🆕 자동 입력 설정 (테이블 조회 기반) + autoFill?: { + enabled: boolean; // 자동 입력 활성화 + sourceTable: string; // 조회할 테이블 (예: company_mng) + filterColumn: string; // 필터링할 컬럼 (예: company_code) + userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드 + displayColumn: string; // 표시할 컬럼 (예: company_name) + }; } /** @@ -121,6 +130,13 @@ export interface DataTableComponent extends BaseComponent { searchable?: boolean; sortable?: boolean; filters?: DataTableFilter[]; + + // 🆕 현재 사용자 정보로 자동 필터링 + autoFilter?: { + enabled: boolean; // 자동 필터 활성화 여부 + filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) + userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 + }; } /** From 6901baab8e8fe552eadf9e12e9132c06ce7728b8 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 16:17:19 +0900 Subject: [PATCH 74/76] =?UTF-8?q?feat(screen-designer):=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EC=BB=AC=EB=9F=BC=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=84=88=EB=B9=84=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경사항: - 격자 설정을 편집 탭에서 항상 표시 (해상도 설정 하단) - 그리드 컬럼 수 동적 조정 가능 (1-24) - 컴포넌트 생성 시 현재 그리드 컬럼 수 기반 자동 계산 - 컴포넌트 너비가 설정한 컬럼 수대로 정확히 표시되도록 수정 수정된 파일: - ScreenDesigner: 컴포넌트 드롭 시 gridColumns와 style.width 동적 계산 - UnifiedPropertiesPanel: 격자 설정 UI 통합, 차지 컬럼 수 설정 시 width 자동 계산 - RealtimePreviewDynamic: getWidth 우선순위 수정, DOM 크기 디버깅 로그 추가 - 8개 컴포넌트: componentStyle.width를 항상 100%로 고정 * ButtonPrimaryComponent * TextInputComponent * NumberInputComponent * TextareaBasicComponent * DateInputComponent * TableListComponent * CardDisplayComponent 문제 해결: - 컴포넌트 내부에서 component.style.width를 재사용하여 이중 축소 발생 - 해결: 부모 컨테이너(RealtimePreviewDynamic)가 width 제어, 컴포넌트는 항상 100% - 결과: 파란 테두리와 내부 콘텐츠가 동일한 크기로 정확히 표시 --- .../src/services/numberingRuleService.ts | 21 +- .../numbering-rule/AutoConfigPanel.tsx | 117 +++-- .../numbering-rule/NumberingRuleCard.tsx | 10 +- .../numbering-rule/NumberingRuleDesigner.tsx | 30 +- .../numbering-rule/NumberingRulePreview.tsx | 32 +- .../components/screen/RealtimePreview.tsx | 17 +- .../screen/RealtimePreviewDynamic.tsx | 125 +++-- frontend/components/screen/ScreenDesigner.tsx | 117 +++-- .../components/screen/panels/GridPanel.tsx | 25 +- .../screen/panels/PropertiesPanel.tsx | 49 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 211 +++++++- .../screen/templates/NumberingRuleTemplate.ts | 77 +++ .../components/screen/widgets/TabsWidget.tsx | 210 ++++++++ .../button-primary/ButtonPrimaryComponent.tsx | 2 + .../card-display/CardDisplayComponent.tsx | 3 + .../checkbox-basic/CheckboxBasicComponent.tsx | 2 + .../date-input/DateInputComponent.tsx | 2 + .../divider-line/DividerLineComponent.tsx | 2 + .../image-display/ImageDisplayComponent.tsx | 2 + .../number-input/NumberInputComponent.tsx | 2 + .../radio-basic/RadioBasicComponent.tsx | 2 + .../slider-basic/SliderBasicComponent.tsx | 2 + .../table-list/TableListComponent.tsx | 6 +- .../text-input/TextInputComponent.tsx | 4 +- .../textarea-basic/TextareaBasicComponent.tsx | 2 + .../toggle-switch/ToggleSwitchComponent.tsx | 2 + frontend/types/numbering-rule.ts | 44 +- 코드_채번_규칙_컴포넌트_구현_계획서.md | 494 ++++++++++++++++++ 28 files changed, 1397 insertions(+), 215 deletions(-) create mode 100644 frontend/components/screen/templates/NumberingRuleTemplate.ts create mode 100644 frontend/components/screen/widgets/TabsWidget.tsx create mode 100644 코드_채번_규칙_컴포넌트_구현_계획서.md diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index bd896845..c61fce29 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -51,6 +51,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -104,6 +106,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_id AS "menuId", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -153,8 +157,9 @@ class NumberingRuleService { const insertRuleQuery = ` INSERT INTO numbering_rules ( rule_id, rule_name, description, separator, reset_period, - current_sequence, table_name, column_name, company_code, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + current_sequence, table_name, column_name, company_code, + menu_objid, scope_type, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING rule_id AS "ruleId", rule_name AS "ruleName", @@ -165,6 +170,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -180,6 +187,8 @@ class NumberingRuleService { config.tableName || null, config.columnName || null, companyCode, + config.menuObjid || null, + config.scopeType || "global", userId, ]); @@ -248,8 +257,10 @@ class NumberingRuleService { reset_period = COALESCE($4, reset_period), table_name = COALESCE($5, table_name), column_name = COALESCE($6, column_name), + menu_objid = COALESCE($7, menu_objid), + scope_type = COALESCE($8, scope_type), updated_at = NOW() - WHERE rule_id = $7 AND company_code = $8 + WHERE rule_id = $9 AND company_code = $10 RETURNING rule_id AS "ruleId", rule_name AS "ruleName", @@ -260,6 +271,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -272,6 +285,8 @@ class NumberingRuleService { updates.resetPeriod, updates.tableName, updates.columnName, + updates.menuObjid, + updates.scopeType, ruleId, companyCode, ]); diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index 1f54cae6..8f05e2e3 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -19,24 +19,7 @@ export const AutoConfigPanel: React.FC = ({ onChange, isPreview = false, }) => { - if (partType === "prefix") { - return ( -
- - onChange({ ...config, prefix: e.target.value })} - placeholder="예: PROD" - disabled={isPreview} - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 코드 앞에 붙을 고정 문자열 -

-
- ); - } - + // 1. 순번 (자동 증가) if (partType === "sequence") { return (
@@ -46,15 +29,15 @@ export const AutoConfigPanel: React.FC = ({ type="number" min={1} max={10} - value={config.sequenceLength || 4} + value={config.sequenceLength || 3} onChange={(e) => - onChange({ ...config, sequenceLength: parseInt(e.target.value) || 4 }) + onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 }) } disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" />

- 예: 4 → 0001, 5 → 00001 + 예: 3 → 001, 4 → 0001

@@ -69,11 +52,56 @@ export const AutoConfigPanel: React.FC = ({ disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" /> +

+ 순번이 시작될 번호 +

); } + // 2. 숫자 (고정 자릿수) + if (partType === "number") { + return ( +
+
+ + + onChange({ ...config, numberLength: parseInt(e.target.value) || 4 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 예: 4 → 0001, 5 → 00001 +

+
+
+ + + onChange({ ...config, numberValue: parseInt(e.target.value) || 0 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 고정으로 사용할 숫자 +

+
+
+ ); + } + + // 3. 날짜 if (partType === "date") { return (
@@ -94,53 +122,28 @@ export const AutoConfigPanel: React.FC = ({ ))} -
- ); - } - - if (partType === "year") { - return ( -
- - -
- ); - } - - if (partType === "month") { - return ( -
- -

- 현재 월이 2자리 형식(01-12)으로 자동 입력됩니다 +

+ 현재 날짜가 자동으로 입력됩니다

); } - if (partType === "custom") { + // 4. 문자 + if (partType === "text") { return (
- + onChange({ ...config, value: e.target.value })} - placeholder="입력값" + value={config.textValue || ""} + onChange={(e) => onChange({ ...config, textValue: e.target.value })} + placeholder="예: PRJ, CODE, PROD" disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" /> +

+ 고정으로 사용할 텍스트 또는 코드 +

); } diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index a6f2cab3..83fcd3a2 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -35,7 +35,7 @@ export const NumberingRuleCard: React.FC = ({ variant="ghost" size="icon" onClick={onDelete} - className="h-7 w-7 text-destructive sm:h-8 sm:w-8" + className="text-destructive h-7 w-7 sm:h-8 sm:w-8" disabled={isPreview} > @@ -75,8 +75,12 @@ export const NumberingRuleCard: React.FC = ({ - 자동 생성 - 직접 입력 + + 자동 생성 + + + 직접 입력 +
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index d318feb0..96c88201 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, Save, Edit2, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; @@ -80,9 +81,9 @@ export const NumberingRuleDesigner: React.FC = ({ const newPart: NumberingRulePart = { id: `part-${Date.now()}`, order: currentRule.parts.length + 1, - partType: "prefix", + partType: "text", generationMethod: "auto", - autoConfig: { prefix: "CODE" }, + autoConfig: { textValue: "CODE" }, }; setCurrentRule((prev) => { @@ -201,6 +202,7 @@ export const NumberingRuleDesigner: React.FC = ({ separator: "-", resetPeriod: "none", currentSequence: 1, + scopeType: "global", }; setSelectedRuleId(newRule.ruleId); @@ -342,6 +344,30 @@ export const NumberingRuleDesigner: React.FC = ({ />
+
+ + +

+ {currentRule.scopeType === "menu" + ? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다" + : "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"} +

+
+ 미리보기 diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index 38e9dbfd..e29cd4f4 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -27,15 +27,21 @@ export const NumberingRulePreview: React.FC = ({ const autoConfig = part.autoConfig || {}; switch (part.partType) { - case "prefix": - return autoConfig.prefix || "PREFIX"; - + // 1. 순번 (자동 증가) case "sequence": { - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; const startFrom = autoConfig.startFrom || 1; return String(startFrom).padStart(length, "0"); } + // 2. 숫자 (고정 자릿수) + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + + // 3. 날짜 case "date": { const format = autoConfig.dateFormat || "YYYYMMDD"; const now = new Date(); @@ -54,21 +60,9 @@ export const NumberingRulePreview: React.FC = ({ } } - case "year": { - const now = new Date(); - const format = autoConfig.dateFormat || "YYYY"; - return format === "YY" - ? String(now.getFullYear()).slice(-2) - : String(now.getFullYear()); - } - - case "month": { - const now = new Date(); - return String(now.getMonth() + 1).padStart(2, "0"); - } - - case "custom": - return autoConfig.value || "CUSTOM"; + // 4. 문자 + case "text": + return autoConfig.textValue || "TEXT"; default: return "XXX"; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 12c300de..906d5ad6 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -399,13 +399,26 @@ export const RealtimePreviewDynamic: React.FC = ({ willUse100Percent: positionX === 0, }); + // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀) + const getWidth = () => { + // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) + if (style?.width) { + return style.width; + } + // 2순위: left가 0이면 100% + if (positionX === 0) { + return "100%"; + } + // 3순위: size.width 픽셀 값 + return size?.width || 200; + }; + const componentStyle = { position: "absolute" as const, ...style, // 먼저 적용하고 left: positionX, top: position?.y || 0, - // 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거) - width: positionX === 0 ? "100%" : (size?.width || 200), + width: getWidth(), // 우선순위에 따른 너비 height: finalHeight, zIndex: position?.z || 1, // right 속성 강제 제거 diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index e90a83ee..1f11182f 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -200,19 +200,58 @@ export const RealtimePreviewDynamic: React.FC = ({ : {}; // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 - // 너비 우선순위: style.width > size.width (픽셀값) + // 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값) const getWidth = () => { - // 1순위: style.width가 있으면 우선 사용 + // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) if (componentStyle?.width) { + console.log("✅ [getWidth] style.width 사용:", { + componentId: id, + label: component.label, + styleWidth: componentStyle.width, + gridColumns: (component as any).gridColumns, + componentStyle: componentStyle, + baseStyle: { + left: `${position.x}px`, + top: `${position.y}px`, + width: componentStyle.width, + height: getHeight(), + }, + }); return componentStyle.width; } - // 2순위: size.width (픽셀) - if (component.componentConfig?.type === "table-list") { - return `${Math.max(size?.width || 120, 120)}px`; + // 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외) + const isButtonComponent = + (component.type === "widget" && (component as WidgetComponent).widgetType === "button") || + (component.type === "component" && (component as any).componentType?.includes("button")); + + if (position.x === 0 && !isButtonComponent) { + console.log("⚠️ [getWidth] 100% 사용 (x=0):", { + componentId: id, + label: component.label, + }); + return "100%"; } - return `${size?.width || 100}px`; + // 3순위: size.width (픽셀) + if (component.componentConfig?.type === "table-list") { + const width = `${Math.max(size?.width || 120, 120)}px`; + console.log("📏 [getWidth] 픽셀 사용 (table-list):", { + componentId: id, + label: component.label, + width, + }); + return width; + } + + const width = `${size?.width || 100}px`; + console.log("📏 [getWidth] 픽셀 사용 (기본):", { + componentId: id, + label: component.label, + width, + sizeWidth: size?.width, + }); + return width; }; const getHeight = () => { @@ -235,35 +274,54 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${size?.height || 40}px`; }; - // 버튼 컴포넌트인지 확인 - const isButtonComponent = - (component.type === "widget" && (component as WidgetComponent).widgetType === "button") || - (component.type === "component" && (component as any).componentType?.includes("button")); - - // 버튼일 경우 로그 출력 (편집기) - if (isButtonComponent && isDesignMode) { - console.log("🎨 [편집기] 버튼 위치:", { - label: component.label, - positionX: position.x, - positionY: position.y, - sizeWidth: size?.width, - sizeHeight: size?.height, - }); - } - const baseStyle = { left: `${position.x}px`, top: `${position.y}px`, - // x=0인 컴포넌트는 전체 너비 사용 (버튼 제외) - width: (position.x === 0 && !isButtonComponent) ? "100%" : getWidth(), + width: getWidth(), // getWidth()가 모든 우선순위를 처리 height: getHeight(), zIndex: component.type === "layout" ? 1 : position.z || 2, ...componentStyle, - // x=0인 컴포넌트는 100% 너비 강제 (버튼 제외) - ...(position.x === 0 && !isButtonComponent && { width: "100%" }), right: undefined, }; + // 🔍 DOM 렌더링 후 실제 크기 측정 + const innerDivRef = React.useRef(null); + const outerDivRef = React.useRef(null); + + React.useEffect(() => { + if (outerDivRef.current && innerDivRef.current) { + const outerRect = outerDivRef.current.getBoundingClientRect(); + const innerRect = innerDivRef.current.getBoundingClientRect(); + const computedOuter = window.getComputedStyle(outerDivRef.current); + const computedInner = window.getComputedStyle(innerDivRef.current); + + console.log("📐 [DOM 실제 크기 상세]:", { + componentId: id, + label: component.label, + gridColumns: (component as any).gridColumns, + "1. baseStyle.width": baseStyle.width, + "2. 외부 div (파란 테두리)": { + width: `${outerRect.width}px`, + height: `${outerRect.height}px`, + computedWidth: computedOuter.width, + computedHeight: computedOuter.height, + }, + "3. 내부 div (컨텐츠 래퍼)": { + width: `${innerRect.width}px`, + height: `${innerRect.height}px`, + computedWidth: computedInner.width, + computedHeight: computedInner.height, + className: innerDivRef.current.className, + inlineStyle: innerDivRef.current.getAttribute("style"), + }, + "4. 너비 비교": { + "외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`, + "비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`, + }, + }); + } + }, [id, component.label, (component as any).gridColumns, baseStyle.width]); + const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(e); @@ -285,7 +343,9 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
= ({ > {/* 동적 컴포넌트 렌더링 */}
{ + // 멀티 ref 처리 + innerDivRef.current = node; + if (component.type === "component" && (component as any).componentType === "flow-widget") { + (contentRef as any).current = node; + } + }} + className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`} + style={{ width: "100%", maxWidth: "100%" }} > = { + // 웹타입별 기본 비율 매핑 (12컬럼 기준 비율) + const gridColumnsRatioMap: Record = { // 입력 컴포넌트 (INPUT 카테고리) - "text-input": 4, // 텍스트 입력 (33%) - "number-input": 2, // 숫자 입력 (16.67%) - "email-input": 4, // 이메일 입력 (33%) - "tel-input": 3, // 전화번호 입력 (25%) - "date-input": 3, // 날짜 입력 (25%) - "datetime-input": 4, // 날짜시간 입력 (33%) - "time-input": 2, // 시간 입력 (16.67%) - "textarea-basic": 6, // 텍스트 영역 (50%) - "select-basic": 3, // 셀렉트 (25%) - "checkbox-basic": 2, // 체크박스 (16.67%) - "radio-basic": 3, // 라디오 (25%) - "file-basic": 4, // 파일 (33%) - "file-upload": 4, // 파일 업로드 (33%) - "slider-basic": 3, // 슬라이더 (25%) - "toggle-switch": 2, // 토글 스위치 (16.67%) - "repeater-field-group": 6, // 반복 필드 그룹 (50%) + "text-input": 4 / 12, // 텍스트 입력 (33%) + "number-input": 2 / 12, // 숫자 입력 (16.67%) + "email-input": 4 / 12, // 이메일 입력 (33%) + "tel-input": 3 / 12, // 전화번호 입력 (25%) + "date-input": 3 / 12, // 날짜 입력 (25%) + "datetime-input": 4 / 12, // 날짜시간 입력 (33%) + "time-input": 2 / 12, // 시간 입력 (16.67%) + "textarea-basic": 6 / 12, // 텍스트 영역 (50%) + "select-basic": 3 / 12, // 셀렉트 (25%) + "checkbox-basic": 2 / 12, // 체크박스 (16.67%) + "radio-basic": 3 / 12, // 라디오 (25%) + "file-basic": 4 / 12, // 파일 (33%) + "file-upload": 4 / 12, // 파일 업로드 (33%) + "slider-basic": 3 / 12, // 슬라이더 (25%) + "toggle-switch": 2 / 12, // 토글 스위치 (16.67%) + "repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%) // 표시 컴포넌트 (DISPLAY 카테고리) - "label-basic": 2, // 라벨 (16.67%) - "text-display": 3, // 텍스트 표시 (25%) - "card-display": 8, // 카드 (66.67%) - "badge-basic": 1, // 배지 (8.33%) - "alert-basic": 6, // 알림 (50%) - "divider-basic": 12, // 구분선 (100%) - "divider-line": 12, // 구분선 (100%) - "accordion-basic": 12, // 아코디언 (100%) - "table-list": 12, // 테이블 리스트 (100%) - "image-display": 4, // 이미지 표시 (33%) - "split-panel-layout": 6, // 분할 패널 레이아웃 (50%) - "flow-widget": 12, // 플로우 위젯 (100%) + "label-basic": 2 / 12, // 라벨 (16.67%) + "text-display": 3 / 12, // 텍스트 표시 (25%) + "card-display": 8 / 12, // 카드 (66.67%) + "badge-basic": 1 / 12, // 배지 (8.33%) + "alert-basic": 6 / 12, // 알림 (50%) + "divider-basic": 1, // 구분선 (100%) + "divider-line": 1, // 구분선 (100%) + "accordion-basic": 1, // 아코디언 (100%) + "table-list": 1, // 테이블 리스트 (100%) + "image-display": 4 / 12, // 이미지 표시 (33%) + "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) + "flow-widget": 1, // 플로우 위젯 (100%) // 액션 컴포넌트 (ACTION 카테고리) - "button-basic": 1, // 버튼 (8.33%) - "button-primary": 1, // 프라이머리 버튼 (8.33%) - "button-secondary": 1, // 세컨더리 버튼 (8.33%) - "icon-button": 1, // 아이콘 버튼 (8.33%) + "button-basic": 1 / 12, // 버튼 (8.33%) + "button-primary": 1 / 12, // 프라이머리 버튼 (8.33%) + "button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%) + "icon-button": 1 / 12, // 아이콘 버튼 (8.33%) // 레이아웃 컴포넌트 - "container-basic": 6, // 컨테이너 (50%) - "section-basic": 12, // 섹션 (100%) - "panel-basic": 6, // 패널 (50%) + "container-basic": 6 / 12, // 컨테이너 (50%) + "section-basic": 1, // 섹션 (100%) + "panel-basic": 6 / 12, // 패널 (50%) // 기타 - "image-basic": 4, // 이미지 (33%) - "icon-basic": 1, // 아이콘 (8.33%) - "progress-bar": 4, // 프로그레스 바 (33%) - "chart-basic": 6, // 차트 (50%) + "image-basic": 4 / 12, // 이미지 (33%) + "icon-basic": 1 / 12, // 아이콘 (8.33%) + "progress-bar": 4 / 12, // 프로그레스 바 (33%) + "chart-basic": 6 / 12, // 차트 (50%) }; - // defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용 + // defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용 if (component.defaultSize?.gridColumnSpan === "full") { - gridColumns = 12; + gridColumns = currentGridColumns; } else { - // componentId 또는 webType으로 매핑, 없으면 기본값 3 - gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3; + // componentId 또는 webType으로 비율 찾기, 없으면 기본값 25% + const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25; + // 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns) + gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns))); } console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { @@ -2141,6 +2144,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; } + // gridColumns에 맞춰 width를 퍼센트로 계산 + const widthPercent = (gridColumns / currentGridColumns) * 100; + + console.log("🎨 [컴포넌트 생성] 너비 계산:", { + componentName: component.name, + componentId: component.id, + currentGridColumns, + gridColumns, + widthPercent: `${widthPercent}%`, + calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, + }); + const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 @@ -2162,6 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD labelColor: "#212121", labelFontWeight: "500", labelMarginBottom: "4px", + width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비 }, }; @@ -4238,7 +4254,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD { + setLayout((prev) => ({ + ...prev, + gridSettings: newSettings, + })); + }} onDeleteComponent={deleteComponent} onCopyComponent={copyComponent} currentTable={tables.length > 0 ? tables[0] : undefined} diff --git a/frontend/components/screen/panels/GridPanel.tsx b/frontend/components/screen/panels/GridPanel.tsx index 16178e57..a38c4cd6 100644 --- a/frontend/components/screen/panels/GridPanel.tsx +++ b/frontend/components/screen/panels/GridPanel.tsx @@ -127,10 +127,27 @@ export const GridPanel: React.FC = ({
+
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 24) { + updateSetting("columns", value); + } + }} + className="h-8 text-xs" + /> + / 24 +
= ({ className="w-full" />
- 1 - 24 + 1열 + 24열
diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index b45bc517..88643c60 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -109,6 +109,12 @@ interface PropertiesPanelProps { draggedComponent: ComponentData | null; currentPosition: { x: number; y: number; z: number }; }; + gridSettings?: { + columns: number; + gap: number; + padding: number; + snapToGrid: boolean; + }; onUpdateProperty: (path: string, value: unknown) => void; onDeleteComponent: () => void; onCopyComponent: () => void; @@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC = ({ selectedComponent, tables = [], dragState, + gridSettings, onUpdateProperty, onDeleteComponent, onCopyComponent, @@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC = ({ {/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */} {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? ( <> - {/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */} + {/* 🆕 그리드 컬럼 수 직접 입력 */}
- + +
+ { + const value = parseInt(e.target.value, 10); + const maxColumns = gridSettings?.columns || 12; + if (!isNaN(value) && value >= 1 && value <= maxColumns) { + // gridColumns 업데이트 + onUpdateProperty("gridColumns", value); + + // width를 퍼센트로 계산하여 업데이트 + const widthPercent = (value / maxColumns) * 100; + onUpdateProperty("style.width", `${widthPercent}%`); + + // localWidthSpan도 업데이트 + setLocalWidthSpan(calculateWidthSpan(`${widthPercent}%`, value)); + } + }} + className="h-8 text-xs" + /> + + / {gridSettings?.columns || 12}열 + +
+

+ 이 컴포넌트가 차지할 그리드 컬럼 수 (1-{gridSettings?.columns || 12}) +

+
+ + {/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */} +
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 24) { + updateGridSetting("columns", value); + } + }} + className="h-6 px-2 py-0 text-xs" + style={{ fontSize: "12px" }} + /> + / 24 +
+ updateGridSetting("columns", value)} + className="w-full" + /> +
+ + {/* 간격 */} +
+ + updateGridSetting("gap", value)} + className="w-full" + /> +
+ + {/* 여백 */} +
+ + updateGridSetting("padding", value)} + className="w-full" + /> +
+
+
+ ); + }; + + // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return (
- {/* 해상도 설정만 표시 */} + {/* 해상도 설정과 격자 설정 표시 */}
+ {/* 해상도 설정 */} {currentResolution && onResolutionChange && ( -
-
- -

해상도 설정

+ <> +
+
+ +

해상도 설정

+
+
- -
+ + )} + {/* 격자 설정 */} + {renderGridSettings()} + {/* 안내 메시지 */}
@@ -283,22 +431,31 @@ export const UnifiedPropertiesPanel: React.FC = ({
{(selectedComponent as any).gridColumns !== undefined && (
- - + +
+ { + const value = parseInt(e.target.value, 10); + const maxColumns = gridSettings?.columns || 12; + if (!isNaN(value) && value >= 1 && value <= maxColumns) { + handleUpdate("gridColumns", value); + + // width를 퍼센트로 계산하여 업데이트 + const widthPercent = (value / maxColumns) * 100; + handleUpdate("style.width", `${widthPercent}%`); + } + }} + className="h-6 w-full px-2 py-0 text-xs" + style={{ fontSize: "12px" }} + /> + + /{gridSettings?.columns || 12} + +
)}
@@ -896,6 +1053,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ )} + {/* 격자 설정 - 해상도 설정 아래 표시 */} + {renderGridSettings()} + {gridSettings && onGridSettingsChange && } + {/* 기본 설정 */} {renderBasicTab()} diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts new file mode 100644 index 00000000..ee386c4b --- /dev/null +++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts @@ -0,0 +1,77 @@ +/** + * 채번 규칙 템플릿 + * 화면관리 시스템에 등록하여 드래그앤드롭으로 사용 + */ + +import { Hash } from "lucide-react"; + +export const getDefaultNumberingRuleConfig = () => ({ + template_code: "numbering-rule-designer", + template_name: "코드 채번 규칙", + template_name_eng: "Numbering Rule Designer", + description: "코드 자동 채번 규칙을 설정하는 컴포넌트", + category: "admin" as const, + icon_name: "hash", + default_size: { + width: 1200, + height: 800, + }, + layout_config: { + components: [ + { + type: "numbering-rule" as const, + label: "채번 규칙 설정", + position: { x: 0, y: 0 }, + size: { width: 1200, height: 800 }, + ruleConfig: { + ruleId: "new-rule", + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }, + maxRules: 6, + style: { + padding: "16px", + backgroundColor: "#ffffff", + }, + }, + ], + }, +}); + +/** + * 템플릿 패널에서 사용할 컴포넌트 정보 + */ +export const numberingRuleTemplate = { + id: "numbering-rule", + name: "채번 규칙", + description: "코드 자동 채번 규칙 설정", + category: "admin" as const, + icon: Hash, + defaultSize: { width: 1200, height: 800 }, + components: [ + { + type: "numbering-rule" as const, + widgetType: undefined, + label: "채번 규칙 설정", + position: { x: 0, y: 0 }, + size: { width: 1200, height: 800 }, + style: { + padding: "16px", + backgroundColor: "#ffffff", + }, + ruleConfig: { + ruleId: "new-rule", + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }, + maxRules: 6, + }, + ], +}; + diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx new file mode 100644 index 00000000..03dec3ba --- /dev/null +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { TabsComponent, TabItem, ScreenDefinition } from "@/types"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, FileQuestion } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; + +interface TabsWidgetProps { + component: TabsComponent; + isPreview?: boolean; +} + +/** + * 탭 위젯 컴포넌트 + * 각 탭에 다른 화면을 표시할 수 있습니다 + */ +export const TabsWidget: React.FC = ({ component, isPreview = false }) => { + // componentConfig에서 설정 읽기 (새 컴포넌트 시스템) + const config = (component as any).componentConfig || component; + const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config; + + // console.log("🔍 TabsWidget 렌더링:", { + // component, + // componentConfig: (component as any).componentConfig, + // tabs, + // tabsLength: tabs.length + // }); + + const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); + const [loadedScreens, setLoadedScreens] = useState>({}); + const [loadingScreens, setLoadingScreens] = useState>({}); + const [screenErrors, setScreenErrors] = useState>({}); + + // 탭 변경 시 화면 로드 + useEffect(() => { + if (!activeTab) return; + + const currentTab = tabs.find((tab) => tab.id === activeTab); + if (!currentTab || !currentTab.screenId) return; + + // 이미 로드된 화면이면 스킵 + if (loadedScreens[activeTab]) return; + + // 이미 로딩 중이면 스킵 + if (loadingScreens[activeTab]) return; + + // 화면 로드 시작 + loadScreen(activeTab, currentTab.screenId); + }, [activeTab, tabs]); + + const loadScreen = async (tabId: string, screenId: number) => { + setLoadingScreens((prev) => ({ ...prev, [tabId]: true })); + setScreenErrors((prev) => ({ ...prev, [tabId]: "" })); + + try { + const layoutData = await screenApi.getLayout(screenId); + + if (layoutData) { + setLoadedScreens((prev) => ({ + ...prev, + [tabId]: { + screenId, + layout: layoutData, + }, + })); + } else { + setScreenErrors((prev) => ({ + ...prev, + [tabId]: "화면을 불러올 수 없습니다", + })); + } + } catch (error: any) { + setScreenErrors((prev) => ({ + ...prev, + [tabId]: error.message || "화면 로드 중 오류가 발생했습니다", + })); + } finally { + setLoadingScreens((prev) => ({ ...prev, [tabId]: false })); + } + }; + + // 탭 콘텐츠 렌더링 + const renderTabContent = (tab: TabItem) => { + const isLoading = loadingScreens[tab.id]; + const error = screenErrors[tab.id]; + const screenData = loadedScreens[tab.id]; + + // 로딩 중 + if (isLoading) { + return ( +
+ +

화면을 불러오는 중...

+
+ ); + } + + // 에러 발생 + if (error) { + return ( +
+ +
+

화면 로드 실패

+

{error}

+
+
+ ); + } + + // 화면 ID가 없는 경우 + if (!tab.screenId) { + return ( +
+ +
+

화면이 할당되지 않았습니다

+

상세설정에서 화면을 선택하세요

+
+
+ ); + } + + // 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링 + if (screenData && screenData.layout && screenData.layout.components) { + const components = screenData.layout.components; + const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 }; + + return ( +
+
+ {components.map((comp) => ( + + ))} +
+
+ ); + } + + return ( +
+ +
+

화면 데이터를 불러올 수 없습니다

+
+
+ ); + }; + + // 빈 탭 목록 + if (tabs.length === 0) { + return ( + +
+

탭이 없습니다

+

상세설정에서 탭을 추가하세요

+
+
+ ); + } + + return ( +
+ + + {tabs.map((tab) => ( + + {tab.label} + {tab.screenName && ( + + {tab.screenName} + + )} + + ))} + + + {tabs.map((tab) => ( + + {renderTabContent(tab)} + + ))} + +
+ ); +}; + diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 93d96ca0..5bf11eec 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 64bdaac9..0912afd7 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -185,6 +185,9 @@ export const CardDisplayComponent: React.FC = ({ position: "relative", backgroundColor: "transparent", }; + + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 if (isDesignMode) { componentStyle.border = "1px dashed hsl(var(--border))"; diff --git a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx index 0fd31f25..2f2c5622 100644 --- a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx +++ b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx @@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/date-input/DateInputComponent.tsx b/frontend/lib/registry/components/date-input/DateInputComponent.tsx index 29b25029..928df3de 100644 --- a/frontend/lib/registry/components/date-input/DateInputComponent.tsx +++ b/frontend/lib/registry/components/date-input/DateInputComponent.tsx @@ -204,6 +204,8 @@ export const DateInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx index 45d5089f..5cc4fcfd 100644 --- a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx +++ b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx @@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index bda6fd8b..13a7ac4f 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx index e5d49328..3f41505c 100644 --- a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx +++ b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx @@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx index 41c91032..8d196db7 100644 --- a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx +++ b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx @@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx b/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx index c23cf50b..22c364fb 100644 --- a/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx +++ b/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx @@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 4e5243b6..46c03aef 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -234,6 +234,8 @@ export const TableListComponent: React.FC = ({ backgroundColor: "hsl(var(--background))", overflow: "hidden", ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // ======================================== @@ -1167,7 +1169,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({ )} {/* 테이블 컨테이너 */} -
+
{/* 스크롤 영역 */}
= ({ height: "100%", ...component.style, ...style, - // 숨김 기능: 편집 모드에서만 연하게 표시 + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", + // 숨김 기능: 편집 모드에서만 연하게 표시 ...(isHidden && isDesignMode && { opacity: 0.4, diff --git a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx index e842ff34..eea2f113 100644 --- a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx +++ b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx @@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx index a3a3786e..4d9fcbe2 100644 --- a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx +++ b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx @@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index dbdbf9bd..9cd81bdb 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -4,15 +4,13 @@ */ /** - * 코드 파트 유형 + * 코드 파트 유형 (4가지) */ export type CodePartType = - | "prefix" // 접두사 (고정 문자열) - | "sequence" // 순번 (자동 증가) - | "date" // 날짜 (YYYYMMDD 등) - | "year" // 연도 (YYYY) - | "month" // 월 (MM) - | "custom"; // 사용자 정의 + | "sequence" // 순번 (자동 증가 숫자) + | "number" // 숫자 (고정 자릿수) + | "date" // 날짜 (다양한 날짜 형식) + | "text"; // 문자 (텍스트) /** * 생성 방식 @@ -43,11 +41,19 @@ export interface NumberingRulePart { // 자동 생성 설정 autoConfig?: { - prefix?: string; // 접두사 - sequenceLength?: number; // 순번 자릿수 - startFrom?: number; // 시작 번호 + // 순번용 + sequenceLength?: number; // 순번 자릿수 (예: 3 → 001) + startFrom?: number; // 시작 번호 (기본: 1) + + // 숫자용 + numberLength?: number; // 숫자 자릿수 (예: 4 → 0001) + numberValue?: number; // 숫자 값 + + // 날짜용 dateFormat?: DateFormat; // 날짜 형식 - value?: string; // 커스텀 값 + + // 문자용 + textValue?: string; // 텍스트 값 (예: "PRJ", "CODE") }; // 직접 입력 설정 @@ -74,6 +80,10 @@ export interface NumberingRuleConfig { resetPeriod?: "none" | "daily" | "monthly" | "yearly"; currentSequence?: number; // 현재 시퀀스 + // 적용 범위 + scopeType?: "global" | "menu"; // 적용 범위 (전역/메뉴별) + menuObjid?: number; // 적용할 메뉴 OBJID (상위 메뉴 기준) + // 적용 대상 tableName?: string; // 적용할 테이블명 columnName?: string; // 적용할 컬럼명 @@ -88,13 +98,11 @@ export interface NumberingRuleConfig { /** * UI 옵션 상수 */ -export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string }> = [ - { value: "prefix", label: "접두사" }, - { value: "sequence", label: "순번" }, - { value: "date", label: "날짜" }, - { value: "year", label: "연도" }, - { value: "month", label: "월" }, - { value: "custom", label: "사용자 정의" }, +export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; description: string }> = [ + { value: "sequence", label: "순번", description: "자동 증가 순번 (1, 2, 3...)" }, + { value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" }, + { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, + { value: "text", label: "문자", description: "텍스트 또는 코드" }, ]; export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ diff --git a/코드_채번_규칙_컴포넌트_구현_계획서.md b/코드_채번_규칙_컴포넌트_구현_계획서.md new file mode 100644 index 00000000..69af1e04 --- /dev/null +++ b/코드_채번_규칙_컴포넌트_구현_계획서.md @@ -0,0 +1,494 @@ +# 코드 채번 규칙 컴포넌트 구현 계획서 + +## 문서 정보 +- **작성일**: 2025-11-03 +- **목적**: Shadcn/ui 가이드라인 기반 코드 채번 규칙 컴포넌트 구현 +- **우선순위**: 중간 +- **디자인 원칙**: 심플하고 깔끔한 UI, 중첩 박스 금지, 일관된 컬러 시스템 + +--- + +## 1. 기능 요구사항 + +### 1.1 핵심 기능 +- 코드 채번 규칙 생성/수정/삭제 +- 동적 규칙 파트 추가/삭제 (최대 6개) +- 실시간 코드 미리보기 +- 규칙 순서 조정 +- 데이터베이스 저장 및 불러오기 + +### 1.2 UI 요구사항 +- 좌측: 코드 목록 (선택적) +- 우측: 규칙 설정 영역 +- 상단: 코드 미리보기 + 규칙명 +- 중앙: 규칙 카드 리스트 +- 하단: 규칙 추가 + 저장 버튼 + +--- + +## 2. 디자인 시스템 (Shadcn/ui 기반) + +### 2.1 색상 사용 규칙 + +```tsx +// 배경 +bg-background // 페이지 배경 +bg-card // 카드 배경 +bg-muted // 약한 배경 (미리보기 등) + +// 텍스트 +text-foreground // 기본 텍스트 +text-muted-foreground // 보조 텍스트 +text-primary // 강조 텍스트 + +// 테두리 +border-border // 기본 테두리 +border-input // 입력 필드 테두리 + +// 버튼 +bg-primary // 주요 버튼 (저장, 추가) +bg-destructive // 삭제 버튼 +variant="outline" // 보조 버튼 (취소) +variant="ghost" // 아이콘 버튼 +``` + +### 2.2 간격 시스템 + +```tsx +// 카드 간 간격 +gap-6 // 24px (카드 사이) + +// 카드 내부 패딩 +p-6 // 24px (CardContent) + +// 폼 필드 간격 +space-y-4 // 16px (입력 필드들) +space-y-3 // 12px (모바일) + +// 섹션 간격 +space-y-6 // 24px (큰 섹션) +``` + +### 2.3 타이포그래피 + +```tsx +// 페이지 제목 +text-2xl font-semibold + +// 섹션 제목 +text-lg font-semibold + +// 카드 제목 +text-base font-semibold + +// 라벨 +text-sm font-medium + +// 본문 텍스트 +text-sm text-muted-foreground + +// 작은 텍스트 +text-xs text-muted-foreground +``` + +### 2.4 반응형 설정 + +```tsx +// 모바일 우선 + 데스크톱 최적화 +className="text-xs sm:text-sm" // 폰트 크기 +className="h-8 sm:h-10" // 입력 필드 높이 +className="flex-col md:flex-row" // 레이아웃 +className="gap-2 sm:gap-4" // 간격 +``` + +### 2.5 중첩 박스 금지 원칙 + +**❌ 잘못된 예시**: +```tsx + + +
{/* 중첩 박스! */} +
{/* 또 중첩! */} + 내용 +
+
+
+
+``` + +**✅ 올바른 예시**: +```tsx + + + 제목 + + + {/* 직접 컨텐츠 배치 */} +
내용 1
+
내용 2
+
+
+``` + +--- + +## 3. 데이터 구조 + +### 3.1 타입 정의 + +```typescript +// frontend/types/numbering-rule.ts + +import { BaseComponent } from "./screen-management"; + +/** + * 코드 파트 유형 + */ +export type CodePartType = + | "prefix" // 접두사 (고정 문자열) + | "sequence" // 순번 (자동 증가) + | "date" // 날짜 (YYYYMMDD 등) + | "year" // 연도 (YYYY) + | "month" // 월 (MM) + | "custom"; // 사용자 정의 + +/** + * 생성 방식 + */ +export type GenerationMethod = + | "auto" // 자동 생성 + | "manual"; // 직접 입력 + +/** + * 날짜 형식 + */ +export type DateFormat = + | "YYYY" // 2025 + | "YY" // 25 + | "YYYYMM" // 202511 + | "YYMM" // 2511 + | "YYYYMMDD" // 20251103 + | "YYMMDD"; // 251103 + +/** + * 단일 규칙 파트 + */ +export interface NumberingRulePart { + id: string; // 고유 ID + order: number; // 순서 (1-6) + partType: CodePartType; // 파트 유형 + generationMethod: GenerationMethod; // 생성 방식 + + // 자동 생성 설정 + autoConfig?: { + // 접두사 설정 + prefix?: string; // 예: "ITM" + + // 순번 설정 + sequenceLength?: number; // 자릿수 (예: 4 → 0001) + startFrom?: number; // 시작 번호 (기본: 1) + + // 날짜 설정 + dateFormat?: DateFormat; // 날짜 형식 + }; + + // 직접 입력 설정 + manualConfig?: { + value: string; // 입력값 + placeholder?: string; // 플레이스홀더 + }; + + // 생성된 값 (미리보기용) + generatedValue?: string; +} + +/** + * 전체 채번 규칙 + */ +export interface NumberingRuleConfig { + ruleId: string; // 규칙 ID + ruleName: string; // 규칙명 + description?: string; // 설명 + parts: NumberingRulePart[]; // 규칙 파트 배열 (최대 6개) + + // 설정 + separator?: string; // 구분자 (기본: "-") + resetPeriod?: "none" | "daily" | "monthly" | "yearly"; // 초기화 주기 + currentSequence?: number; // 현재 시퀀스 + + // 적용 대상 + tableName?: string; // 적용할 테이블명 + columnName?: string; // 적용할 컬럼명 + + // 메타 정보 + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +/** + * 화면관리 컴포넌트 인터페이스 + */ +export interface NumberingRuleComponent extends BaseComponent { + type: "numbering-rule"; + + // 채번 규칙 설정 + ruleConfig: NumberingRuleConfig; + + // UI 설정 + showRuleList?: boolean; // 좌측 목록 표시 여부 + maxRules?: number; // 최대 규칙 개수 (기본: 6) + enableReorder?: boolean; // 순서 변경 허용 여부 + + // 스타일 + cardLayout?: "vertical" | "horizontal"; // 카드 레이아웃 +} +``` + +### 3.2 데이터베이스 스키마 + +```sql +-- db/migrations/034_create_numbering_rules.sql + +-- 채번 규칙 마스터 테이블 +CREATE TABLE IF NOT EXISTS numbering_rules ( + rule_id VARCHAR(50) PRIMARY KEY, + rule_name VARCHAR(100) NOT NULL, + description TEXT, + separator VARCHAR(10) DEFAULT '-', + reset_period VARCHAR(20) DEFAULT 'none', + current_sequence INTEGER DEFAULT 1, + table_name VARCHAR(100), + column_name VARCHAR(100), + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code) +); + +-- 채번 규칙 상세 테이블 +CREATE TABLE IF NOT EXISTS numbering_rule_parts ( + id SERIAL PRIMARY KEY, + rule_id VARCHAR(50) NOT NULL, + part_order INTEGER NOT NULL, + part_type VARCHAR(50) NOT NULL, + generation_method VARCHAR(20) NOT NULL, + auto_config JSONB, + manual_config JSONB, + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_numbering_rule FOREIGN KEY (rule_id) + REFERENCES numbering_rules(rule_id) ON DELETE CASCADE, + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code), + CONSTRAINT unique_rule_order UNIQUE (rule_id, part_order, company_code) +); + +-- 인덱스 +CREATE INDEX idx_numbering_rules_company ON numbering_rules(company_code); +CREATE INDEX idx_numbering_rule_parts_rule ON numbering_rule_parts(rule_id); +CREATE INDEX idx_numbering_rules_table ON numbering_rules(table_name, column_name); + +-- 샘플 데이터 +INSERT INTO numbering_rules (rule_id, rule_name, description, company_code, created_by) +VALUES ('SAMPLE_RULE', '샘플 채번 규칙', '제품 코드 자동 생성', '*', 'system') +ON CONFLICT (rule_id) DO NOTHING; + +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, company_code) +VALUES + ('SAMPLE_RULE', 1, 'prefix', 'auto', '{"prefix": "PROD"}', '*'), + ('SAMPLE_RULE', 2, 'date', 'auto', '{"dateFormat": "YYYYMMDD"}', '*'), + ('SAMPLE_RULE', 3, 'sequence', 'auto', '{"sequenceLength": 4, "startFrom": 1}', '*') +ON CONFLICT (rule_id, part_order, company_code) DO NOTHING; +``` + +--- + +## 4. 구현 순서 + +### Phase 1: 타입 정의 및 스키마 생성 ✅ +1. 타입 정의 파일 생성 +2. 데이터베이스 마이그레이션 실행 +3. 샘플 데이터 삽입 + +### Phase 2: 백엔드 API 구현 +1. Controller 생성 +2. Service 레이어 구현 +3. API 테스트 + +### Phase 3: 프론트엔드 기본 컴포넌트 +1. NumberingRuleDesigner (메인) +2. NumberingRulePreview (미리보기) +3. NumberingRuleCard (단일 규칙 카드) + +### Phase 4: 상세 설정 패널 +1. PartTypeSelector (파트 유형 선택) +2. AutoConfigPanel (자동 생성 설정) +3. ManualConfigPanel (직접 입력 설정) + +### Phase 5: 화면관리 통합 +1. ComponentType에 "numbering-rule" 추가 +2. RealtimePreview 렌더링 추가 +3. 템플릿 등록 +4. 속성 패널 구현 + +### Phase 6: 테스트 및 최적화 +1. 기능 테스트 +2. 반응형 테스트 +3. 성능 최적화 +4. 문서화 + +--- + +## 5. 구현 완료 ✅ + +### Phase 1: 타입 정의 및 스키마 생성 ✅ +- ✅ `frontend/types/numbering-rule.ts` 생성 +- ✅ `db/migrations/034_create_numbering_rules.sql` 생성 및 실행 +- ✅ 샘플 데이터 삽입 완료 + +### Phase 2: 백엔드 API 구현 ✅ +- ✅ `backend-node/src/services/numberingRuleService.ts` 생성 +- ✅ `backend-node/src/controllers/numberingRuleController.ts` 생성 +- ✅ `app.ts`에 라우터 등록 (`/api/numbering-rules`) +- ✅ 백엔드 재시작 완료 + +### Phase 3: 프론트엔드 기본 컴포넌트 ✅ +- ✅ `NumberingRulePreview.tsx` - 코드 미리보기 +- ✅ `NumberingRuleCard.tsx` - 단일 규칙 카드 +- ✅ `AutoConfigPanel.tsx` - 자동 생성 설정 +- ✅ `ManualConfigPanel.tsx` - 직접 입력 설정 +- ✅ `NumberingRuleDesigner.tsx` - 메인 디자이너 + +### Phase 4: 상세 설정 패널 ✅ +- ✅ 파트 유형별 설정 UI (접두사, 순번, 날짜, 연도, 월, 커스텀) +- ✅ 자동 생성 / 직접 입력 모드 전환 +- ✅ 실시간 미리보기 업데이트 + +### Phase 5: 화면관리 시스템 통합 ✅ +- ✅ `unified-core.ts`에 "numbering-rule" ComponentType 추가 +- ✅ `screen-management.ts`에 ComponentData 유니온 타입 추가 +- ✅ `RealtimePreview.tsx`에 렌더링 로직 추가 +- ✅ `TemplatesPanel.tsx`에 "관리자" 카테고리 및 템플릿 추가 +- ✅ `NumberingRuleTemplate.ts` 생성 + +### Phase 6: 완료 ✅ +모든 단계가 성공적으로 완료되었습니다! + +--- + +## 6. 사용 방법 + +### 6.1 화면관리에서 사용하기 + +1. **화면관리** 페이지로 이동 +2. 좌측 **템플릿 패널**에서 **관리자** 카테고리 선택 +3. **코드 채번 규칙** 템플릿을 캔버스로 드래그 +4. 규칙 파트 추가 및 설정 +5. 저장 + +### 6.2 API 사용하기 + +#### 규칙 목록 조회 +```bash +GET /api/numbering-rules +``` + +#### 규칙 생성 +```bash +POST /api/numbering-rules +{ + "ruleId": "PROD_CODE", + "ruleName": "제품 코드 규칙", + "parts": [ + { + "id": "part-1", + "order": 1, + "partType": "prefix", + "generationMethod": "auto", + "autoConfig": { "prefix": "PROD" } + }, + { + "id": "part-2", + "order": 2, + "partType": "date", + "generationMethod": "auto", + "autoConfig": { "dateFormat": "YYYYMMDD" } + }, + { + "id": "part-3", + "order": 3, + "partType": "sequence", + "generationMethod": "auto", + "autoConfig": { "sequenceLength": 4, "startFrom": 1 } + } + ], + "separator": "-" +} +``` + +#### 코드 생성 +```bash +POST /api/numbering-rules/PROD_CODE/generate + +응답: { "success": true, "data": { "code": "PROD-20251103-0001" } } +``` + +--- + +## 7. 구현된 파일 목록 + +### 프론트엔드 +``` +frontend/ +├── types/ +│ └── numbering-rule.ts ✅ +├── components/ +│ └── numbering-rule/ +│ ├── NumberingRuleDesigner.tsx ✅ +│ ├── NumberingRuleCard.tsx ✅ +│ ├── NumberingRulePreview.tsx ✅ +│ ├── AutoConfigPanel.tsx ✅ +│ └── ManualConfigPanel.tsx ✅ +└── components/screen/ + ├── RealtimePreview.tsx ✅ (수정됨) + ├── panels/ + │ └── TemplatesPanel.tsx ✅ (수정됨) + └── templates/ + └── NumberingRuleTemplate.ts ✅ +``` + +### 백엔드 +``` +backend-node/ +├── src/ +│ ├── services/ +│ │ └── numberingRuleService.ts ✅ +│ ├── controllers/ +│ │ └── numberingRuleController.ts ✅ +│ └── app.ts ✅ (수정됨) +``` + +### 데이터베이스 +``` +db/ +└── migrations/ + └── 034_create_numbering_rules.sql ✅ +``` + +--- + +## 8. 다음 개선 사항 (선택사항) + +- [ ] 규칙 순서 드래그앤드롭으로 변경 +- [ ] 규칙 복제 기능 +- [ ] 규칙 템플릿 제공 (자주 사용하는 패턴) +- [ ] 코드 검증 로직 +- [ ] 테이블 생성 시 자동 채번 컬럼 추가 통합 +- [ ] 화면관리에서 입력 폼에 자동 코드 생성 버튼 추가 + From 37796ecc9d36bd61902530f97f76abfa45fe341a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 16:18:12 +0900 Subject: [PATCH 75/76] =?UTF-8?q?fix:=20FileComponentConfigPanel=EC=97=90?= =?UTF-8?q?=20cn=20=ED=95=A8=EC=88=98=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/panels/FileComponentConfigPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/screen/panels/FileComponentConfigPanel.tsx b/frontend/components/screen/panels/FileComponentConfigPanel.tsx index 8db01ad0..f14c861f 100644 --- a/frontend/components/screen/panels/FileComponentConfigPanel.tsx +++ b/frontend/components/screen/panels/FileComponentConfigPanel.tsx @@ -12,7 +12,7 @@ import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide import { Button } from "@/components/ui/button"; import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types"; import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; -import { formatFileSize } from "@/lib/utils"; +import { formatFileSize, cn } from "@/lib/utils"; import { toast } from "sonner"; interface FileComponentConfigPanelProps { From b8e30c9557341227fdb715b8ec89f21622a24857 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 16:21:24 +0900 Subject: [PATCH 76/76] =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/panels/UnifiedPropertiesPanel.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 32200450..01716bb0 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -180,27 +180,21 @@ export const UnifiedPropertiesPanel: React.FC = ({ id="columns" type="number" min={1} - max={24} value={gridSettings.columns} onChange={(e) => { const value = parseInt(e.target.value, 10); - if (!isNaN(value) && value >= 1 && value <= 24) { + if (!isNaN(value) && value >= 1) { updateGridSetting("columns", value); } }} className="h-6 px-2 py-0 text-xs" style={{ fontSize: "12px" }} + placeholder="1 이상의 숫자" /> - / 24
- updateGridSetting("columns", value)} - className="w-full" - /> +

+ 1 이상의 숫자를 입력하세요 +

{/* 간격 */}