From 6e0ae8e9dff9c94931ab44a94a88122cebb8ebb0 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 10:46:10 +0900 Subject: [PATCH 01/24] =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/AddColumnModal.tsx | 34 +- .../components/admin/AdvancedBatchModal.tsx | 2 +- frontend/components/admin/BatchJobModal.tsx | 22 +- .../admin/CodeCategoryFormModal.tsx | 16 +- frontend/components/admin/CodeFormModal.tsx | 16 +- .../admin/CollectionConfigModal.tsx | 22 +- .../components/admin/CompanyFormModal.tsx | 32 +- .../components/admin/CreateTableModal.tsx | 38 +- frontend/components/admin/DDLLogViewer.tsx | 28 +- .../admin/ExternalCallConfigModal.tsx | 34 +- .../admin/ExternalDbConnectionModal.tsx | 34 +- frontend/components/admin/LangKeyModal.tsx | 26 +- frontend/components/admin/LanguageModal.tsx | 26 +- frontend/components/admin/LayoutFormModal.tsx | 24 +- frontend/components/admin/MenuFormModal.tsx | 26 +- .../admin/RestApiConnectionModal.tsx | 32 +- frontend/components/admin/RoleDeleteModal.tsx | 32 +- frontend/components/admin/RoleFormModal.tsx | 32 +- frontend/components/admin/SqlQueryModal.tsx | 36 +- frontend/components/admin/TableLogViewer.tsx | 28 +- .../components/admin/TemplateImportExport.tsx | 2 +- frontend/components/admin/UserFormModal.tsx | 30 +- .../components/admin/UserHistoryModal.tsx | 26 +- .../admin/UserPasswordResetModal.tsx | 16 +- .../admin/dashboard/DashboardDesigner.tsx | 30 +- .../admin/dashboard/DashboardSaveModal.tsx | 32 +- .../admin/dashboard/MenuAssignmentModal.tsx | 34 +- .../widgets/yard-3d/MaterialAddModal.tsx | 30 +- .../widgets/yard-3d/MaterialLibrary.tsx | 2 +- .../widgets/yard-3d/YardLayoutCreateModal.tsx | 34 +- .../components/common/BarcodeScanModal.tsx | 47 +- .../components/common/ExcelUploadModal.tsx | 54 +- frontend/components/common/ScreenModal.tsx | 58 +- .../components/common/TableHistoryModal.tsx | 20 +- .../components/common/TableOptionsModal.tsx | 47 +- .../dataflow/ConnectionSetupModal.tsx | 34 +- .../components/dataflow/SaveDiagramModal.tsx | 34 +- .../node-editor/dialogs/LoadFlowDialog.tsx | 2 +- .../components/flow/FlowDataListModal.tsx | 8 +- frontend/components/layout/ProfileModal.tsx | 68 +- frontend/components/mail/MailDetailModal.tsx | 18 +- .../components/multilang/LangKeyModal.tsx | 6 +- .../order/OrderRegistrationModal.tsx | 2 +- .../components/report/ReportCreateModal.tsx | 15 +- .../components/screen/CopyScreenModal.tsx | 2 +- .../components/screen/CreateScreenModal.tsx | 40 +- frontend/components/screen/EditModal.tsx | 55 +- .../screen/FileAttachmentDetailModal.tsx | 6 +- .../screen/InteractiveDataTable.tsx | 6 +- .../screen/InteractiveScreenViewer.tsx | 2 +- .../screen/InteractiveScreenViewerDynamic.tsx | 27 +- .../components/screen/MenuAssignmentModal.tsx | 76 +-- .../screen/ResponsivePreviewModal.tsx | 4 +- frontend/components/screen/SaveModal.tsx | 25 +- .../screen/templates/DataTableTemplate.tsx | 2 +- frontend/components/ui/dialog.tsx | 6 +- frontend/components/ui/resizable-dialog.tsx | 601 ------------------ .../components/webtypes/RepeaterInput.tsx | 91 ++- .../card-display/CardDisplayComponent.tsx | 4 +- .../entity-search-input/EntitySearchModal.tsx | 2 +- .../file-upload/FileViewerModal.tsx | 4 +- .../ItemSelectionModal.tsx | 6 +- .../table-list/TableListComponent.tsx | 90 ++- .../table-list/TableListConfigPanel.tsx | 193 +++--- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 67 files changed, 969 insertions(+), 1465 deletions(-) delete mode 100644 frontend/components/ui/resizable-dialog.tsx diff --git a/frontend/components/admin/AddColumnModal.tsx b/frontend/components/admin/AddColumnModal.tsx index 6b9467d9..550c46fc 100644 --- a/frontend/components/admin/AddColumnModal.tsx +++ b/frontend/components/admin/AddColumnModal.tsx @@ -7,13 +7,13 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -197,14 +197,14 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType); return ( - - - - + + + + 컬럼 추가 - {tableName} - - + +
{/* 검증 오류 표시 */} @@ -346,7 +346,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
- + @@ -365,8 +365,8 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol "컬럼 추가" )} - -
-
+ + + ); } diff --git a/frontend/components/admin/AdvancedBatchModal.tsx b/frontend/components/admin/AdvancedBatchModal.tsx index b1667c36..1276bcad 100644 --- a/frontend/components/admin/AdvancedBatchModal.tsx +++ b/frontend/components/admin/AdvancedBatchModal.tsx @@ -198,7 +198,7 @@ export default function AdvancedBatchModal({ return ( - + 고급 배치 생성 diff --git a/frontend/components/admin/BatchJobModal.tsx b/frontend/components/admin/BatchJobModal.tsx index cc9ca22f..b3fbb0e9 100644 --- a/frontend/components/admin/BatchJobModal.tsx +++ b/frontend/components/admin/BatchJobModal.tsx @@ -7,7 +7,7 @@ import { DialogHeader, -} from "@/components/ui/resizable-dialog"; +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -169,13 +169,13 @@ export default function BatchJobModal({ // 상태 제거 - 필요없음 return ( - - - - + + + + {job ? "배치 작업 수정" : "새 배치 작업"} - - + +
{/* 기본 정보 */} @@ -344,7 +344,7 @@ export default function BatchJobModal({ - + - +
-
-
+
+
); } diff --git a/frontend/components/admin/CodeCategoryFormModal.tsx b/frontend/components/admin/CodeCategoryFormModal.tsx index c7c62818..0ba970e0 100644 --- a/frontend/components/admin/CodeCategoryFormModal.tsx +++ b/frontend/components/admin/CodeCategoryFormModal.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -164,11 +164,11 @@ export function CodeCategoryFormModal({ const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending; return ( - - - - {isEditing ? "카테고리 수정" : "새 카테고리"} - + + + + {isEditing ? "카테고리 수정" : "새 카테고리"} +
{/* 카테고리 코드 */} @@ -383,7 +383,7 @@ export function CodeCategoryFormModal({
-
-
+ + ); } diff --git a/frontend/components/admin/CodeFormModal.tsx b/frontend/components/admin/CodeFormModal.tsx index 2d6c7d39..977e9e84 100644 --- a/frontend/components/admin/CodeFormModal.tsx +++ b/frontend/components/admin/CodeFormModal.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -153,11 +153,11 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending; return ( - - - - {isEditing ? "코드 수정" : "새 코드"} - + + + + {isEditing ? "코드 수정" : "새 코드"} +
{/* 코드값 */} @@ -328,7 +328,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
-
-
+ + ); } diff --git a/frontend/components/admin/CollectionConfigModal.tsx b/frontend/components/admin/CollectionConfigModal.tsx index ef5e4998..ea099bfa 100644 --- a/frontend/components/admin/CollectionConfigModal.tsx +++ b/frontend/components/admin/CollectionConfigModal.tsx @@ -7,7 +7,7 @@ import { DialogHeader, -} from "@/components/ui/resizable-dialog"; +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -164,13 +164,13 @@ export default function CollectionConfigModal({ ]; return ( - - - - + + + + {config ? "수집 설정 수정" : "새 수집 설정"} - - + +
{/* 기본 정보 */} @@ -331,16 +331,16 @@ export default function CollectionConfigModal({ - + - +
-
-
+ + ); } diff --git a/frontend/components/admin/CompanyFormModal.tsx b/frontend/components/admin/CompanyFormModal.tsx index 56b79294..91cff911 100644 --- a/frontend/components/admin/CompanyFormModal.tsx +++ b/frontend/components/admin/CompanyFormModal.tsx @@ -4,13 +4,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber"; @@ -111,8 +111,8 @@ export function CompanyFormModal({ }; return ( - - + - - {isEditMode ? "회사 정보 수정" : "새 회사 등록"} - + + {isEditMode ? "회사 정보 수정" : "새 회사 등록"} +
{/* 회사명 입력 (필수) */} @@ -255,7 +255,7 @@ export function CompanyFormModal({ )}
- + @@ -273,8 +273,8 @@ export function CompanyFormModal({ {(isLoading || isSaving) && } {isEditMode ? "수정" : "등록"} - -
-
+ + + ); } diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index 8de74c32..ecb6b03f 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -7,13 +7,13 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -321,20 +321,20 @@ export function CreateTableModal({ const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType); return ( - - - - + + + + {isDuplicateMode ? "테이블 복제" : "새 테이블 생성"} - - + + {isDuplicateMode ? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.` : "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요." } - - + +
{/* 테이블 기본 정보 */} @@ -452,7 +452,7 @@ export function CreateTableModal({ )}
- + @@ -482,8 +482,8 @@ export function CreateTableModal({ isDuplicateMode ? "복제 생성" : "테이블 생성" )} - -
-
+ + + ); } diff --git a/frontend/components/admin/DDLLogViewer.tsx b/frontend/components/admin/DDLLogViewer.tsx index d4441056..f707511b 100644 --- a/frontend/components/admin/DDLLogViewer.tsx +++ b/frontend/components/admin/DDLLogViewer.tsx @@ -7,12 +7,12 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -148,14 +148,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) { }; return ( - - - - + + + + DDL 실행 로그 및 통계 - - + + @@ -407,7 +407,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) { )} - - + + ); } diff --git a/frontend/components/admin/ExternalCallConfigModal.tsx b/frontend/components/admin/ExternalCallConfigModal.tsx index 0217977a..30694034 100644 --- a/frontend/components/admin/ExternalCallConfigModal.tsx +++ b/frontend/components/admin/ExternalCallConfigModal.tsx @@ -6,13 +6,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; import { @@ -266,13 +266,13 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig }; return ( - - - - + + + + {editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"} - - + +
{/* 기본 정보 */} @@ -564,7 +564,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig )}
- + - -
-
+ + + ); } diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index 1d0c046f..f5631297 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; import { ExternalDbConnectionAPI, @@ -311,13 +311,13 @@ export const ExternalDbConnectionModal: React.FC }; return ( - - - - + + + + {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} - - + +
{/* 기본 정보 */} @@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC
- + - -
-
+ + + ); }; diff --git a/frontend/components/admin/LangKeyModal.tsx b/frontend/components/admin/LangKeyModal.tsx index 034ca213..6801e873 100644 --- a/frontend/components/admin/LangKeyModal.tsx +++ b/frontend/components/admin/LangKeyModal.tsx @@ -2,12 +2,12 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -66,11 +66,11 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani }; return ( - - - - {keyData ? "언어 키 수정" : "새 언어 키 추가"} - + + + + {keyData ? "언어 키 수정" : "새 언어 키 추가"} +
@@ -131,7 +131,7 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
-
-
+ + ); } diff --git a/frontend/components/admin/LanguageModal.tsx b/frontend/components/admin/LanguageModal.tsx index a50f12ef..908ebf0a 100644 --- a/frontend/components/admin/LanguageModal.tsx +++ b/frontend/components/admin/LanguageModal.tsx @@ -2,12 +2,12 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -68,11 +68,11 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }: }; return ( - - - - {languageData ? "언어 수정" : "새 언어 추가"} - + + + + {languageData ? "언어 수정" : "새 언어 추가"} +
@@ -141,8 +141,8 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
- - + +
); } diff --git a/frontend/components/admin/LayoutFormModal.tsx b/frontend/components/admin/LayoutFormModal.tsx index da6b0f3a..a4bcdf4f 100644 --- a/frontend/components/admin/LayoutFormModal.tsx +++ b/frontend/components/admin/LayoutFormModal.tsx @@ -13,7 +13,7 @@ import { DialogHeader, -} from "@/components/ui/resizable-dialog"; +} from "@/components/ui/dialog"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -225,14 +225,14 @@ export const LayoutFormModal: React.FC = ({ open, onOpenCh }; return ( - - - - + + + + 새 레이아웃 생성 - - GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다. - + + GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다. + {/* 단계 표시기 */}
@@ -499,7 +499,7 @@ export const LayoutFormModal: React.FC = ({ open, onOpenCh )}
- + {step !== "basic" && !generationResult && ( - -
-
+ + + ); }; diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index 33d2447e..43f17b52 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -9,11 +9,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { toast } from "sonner"; @@ -684,15 +684,15 @@ export const MenuFormModal: React.FC = ({ }; return ( - - - - + + + + {isEdit ? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE) : getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)} - - + +
@@ -1067,7 +1067,7 @@ export const MenuFormModal: React.FC = ({
-
-
+ + ); }; diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 3de34800..95ac6e76 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; import { ExternalRestApiConnectionAPI, @@ -271,11 +271,11 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: }; return ( - - - - {connection ? "REST API 연결 수정" : "새 REST API 연결 추가"} - + + + + {connection ? "REST API 연결 수정" : "새 REST API 연결 추가"} +
{/* 기본 정보 */} @@ -574,7 +574,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
- + - -
-
+ + + ); } diff --git a/frontend/components/admin/RoleDeleteModal.tsx b/frontend/components/admin/RoleDeleteModal.tsx index 9d178351..9f3cf75a 100644 --- a/frontend/components/admin/RoleDeleteModal.tsx +++ b/frontend/components/admin/RoleDeleteModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useCallback } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { roleAPI, RoleGroup } from "@/lib/api/role"; import { AlertTriangle } from "lucide-react"; @@ -71,11 +71,11 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete if (!role) return null; return ( - - - - 권한 그룹 삭제 - + + + + 권한 그룹 삭제 +
{/* 경고 메시지 */} @@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete )}
- + - -
-
+ + + ); } diff --git a/frontend/components/admin/RoleFormModal.tsx b/frontend/components/admin/RoleFormModal.tsx index 492a463c..cdeeccc5 100644 --- a/frontend/components/admin/RoleFormModal.tsx +++ b/frontend/components/admin/RoleFormModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useCallback, useEffect, useMemo } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -184,11 +184,11 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF ); return ( - - - - {isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"} - + + + + {isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"} +
{/* 권한 그룹명 */} @@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF )}
- + - -
-
+ + + ); } diff --git a/frontend/components/admin/SqlQueryModal.tsx b/frontend/components/admin/SqlQueryModal.tsx index 4c01f472..6f83d78f 100644 --- a/frontend/components/admin/SqlQueryModal.tsx +++ b/frontend/components/admin/SqlQueryModal.tsx @@ -3,12 +3,12 @@ import { useState, useEffect, ChangeEvent } from "react"; import { Button } from "@/components/ui/button"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -179,14 +179,14 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c }; return ( - - - - {connectionName} - SQL 쿼리 실행 - + + + + {connectionName} - SQL 쿼리 실행 + 데이터베이스에 대해 SQL SELECT 쿼리를 실행하고 결과를 확인할 수 있습니다. - - + + {/* 쿼리 입력 영역 */}
@@ -228,7 +228,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c

사용 가능한 테이블

-
+
{tables.map((table) => (
@@ -263,7 +263,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c {loadingColumns ? (
컬럼 정보 로딩 중...
) : selectedTableColumns.length > 0 ? ( -
+
@@ -332,7 +332,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c {/* 결과 그리드 */}
-
+
@@ -378,7 +378,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c - - + + ); }; diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx index 181c6e4b..147229df 100644 --- a/frontend/components/admin/TableLogViewer.tsx +++ b/frontend/components/admin/TableLogViewer.tsx @@ -2,12 +2,12 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -126,14 +126,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer }; return ( - - - - + + + + {tableName} - 변경 이력 - - + + {/* 필터 영역 */}
@@ -261,7 +261,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
-
-
+ + ); } diff --git a/frontend/components/admin/TemplateImportExport.tsx b/frontend/components/admin/TemplateImportExport.tsx index a72bb468..e11dada6 100644 --- a/frontend/components/admin/TemplateImportExport.tsx +++ b/frontend/components/admin/TemplateImportExport.tsx @@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Upload, Download, FileText, AlertCircle, CheckCircle } from "lucide-react"; import { toast } from "sonner"; import { useTemplates } from "@/hooks/admin/useTemplates"; diff --git a/frontend/components/admin/UserFormModal.tsx b/frontend/components/admin/UserFormModal.tsx index b3095e67..a70e82b9 100644 --- a/frontend/components/admin/UserFormModal.tsx +++ b/frontend/components/admin/UserFormModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useCallback, useEffect, useMemo } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -32,11 +32,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod }; return ( - - - - {title} - + + + + {title} +

{message}

@@ -45,8 +45,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod 확인 -
-
+ + ); } @@ -441,11 +441,11 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF return ( <> - - - - {isEditMode ? "사용자 정보 수정" : "사용자 등록"} - + + + + {isEditMode ? "사용자 정보 수정" : "사용자 등록"} +
{/* 기본 정보 */} @@ -684,8 +684,8 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF {isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
-
-
+ + {/* 알림 모달 */} - - - + + + + 사용자 관리 이력 - +
{userName} ({userId})의 변경이력을 조회합니다.
-
+
{/* 로딩 상태 */} @@ -254,7 +254,7 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist 닫기
-
- + + ); } diff --git a/frontend/components/admin/UserPasswordResetModal.tsx b/frontend/components/admin/UserPasswordResetModal.tsx index dc9fd206..086b1556 100644 --- a/frontend/components/admin/UserPasswordResetModal.tsx +++ b/frontend/components/admin/UserPasswordResetModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useCallback } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -127,11 +127,11 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu if (!userId) return null; return ( - - - - 비밀번호 초기화 - + + + + 비밀번호 초기화 +
{/* 대상 사용자 정보 */} @@ -215,7 +215,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu {isLoading ? "처리중..." : "초기화"}
-
+ {/* 알림 모달 */} -
+ ); } diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 4c70419e..08296fd1 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -13,12 +13,12 @@ import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogDescription, - ResizableDialogHeader, - ResizableDialogTitle, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -639,23 +639,23 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D /> {/* 저장 성공 모달 */} - { setSuccessModalOpen(false); router.push("/admin/dashboard"); }} > - - + +
- 저장 완료 - + 저장 완료 + 대시보드가 성공적으로 저장되었습니다. - -
+ +
-
-
+ + {/* 초기화 확인 모달 */} diff --git a/frontend/components/admin/dashboard/DashboardSaveModal.tsx b/frontend/components/admin/dashboard/DashboardSaveModal.tsx index 2c9ff4d6..f99984e8 100644 --- a/frontend/components/admin/dashboard/DashboardSaveModal.tsx +++ b/frontend/components/admin/dashboard/DashboardSaveModal.tsx @@ -2,13 +2,13 @@ import { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -174,11 +174,11 @@ export function DashboardSaveModal({ const flatMenus = flattenMenus(currentMenus); return ( - - - - {isEditing ? "대시보드 수정" : "대시보드 저장"} - + + + + {isEditing ? "대시보드 수정" : "대시보드 저장"} +
{/* 대시보드 이름 */} @@ -312,7 +312,7 @@ export function DashboardSaveModal({
- + @@ -329,8 +329,8 @@ export function DashboardSaveModal({ )} - -
-
+ + + ); } diff --git a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx index 5e8869a9..a6f01c8b 100644 --- a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx +++ b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; @@ -116,14 +116,14 @@ export const MenuAssignmentModal: React.FC = ({ }; return ( - - - + + +
- 대시보드 저장 완료 - '{dashboardTitle}' 대시보드가 저장되었습니다. + 대시보드 저장 완료 + '{dashboardTitle}' 대시보드가 저장되었습니다.
-
+
@@ -200,13 +200,13 @@ export const MenuAssignmentModal: React.FC = ({ )}
- + - - - + + + ); }; diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx index b5831d27..3f64c2b2 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx @@ -3,13 +3,13 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loader2 } from "lucide-react"; @@ -94,10 +94,10 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M if (!open) onClose(); }} > - - - 자재 배치 설정 - + + + 자재 배치 설정 +
{/* 자재 정보 */} @@ -233,7 +233,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
- + @@ -247,8 +247,8 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M "배치" )} - -
-
+ + + ); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx index 15b6db93..79909658 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Search, Loader2 } from "lucide-react"; import { materialApi } from "@/lib/api/yardLayoutApi"; diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx index e6c8a3b8..feac0f22 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx @@ -3,13 +3,13 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -64,14 +64,14 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar }; return ( - - e.stopPropagation()}> - + + e.stopPropagation()}> +
- 새로운 3D필드 생성 - 필드 이름을 입력하세요 + 새로운 3D필드 생성 + 필드 이름을 입력하세요
-
+
@@ -100,7 +100,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar )}
- + @@ -114,8 +114,8 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar "생성" )} - - - + + + ); } diff --git a/frontend/components/common/BarcodeScanModal.tsx b/frontend/components/common/BarcodeScanModal.tsx index 7c615941..34706b8c 100644 --- a/frontend/components/common/BarcodeScanModal.tsx +++ b/frontend/components/common/BarcodeScanModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useRef, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react"; @@ -179,26 +179,15 @@ export const BarcodeScanModal: React.FC = ({ }; return ( - - - - 바코드 스캔 - + + + + 바코드 스캔 + 카메라로 바코드를 스캔하세요. {targetField && ` (대상 필드: ${targetField})`} - 모달 테두리를 드래그하여 크기를 조절할 수 있습니다. - - + +
{/* 카메라 권한 요청 대기 중 */} @@ -337,7 +326,7 @@ export const BarcodeScanModal: React.FC = ({ )}
- + )} - -
-
+ + + ); }; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index c5a25a65..0f080bcc 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useRef, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { @@ -385,27 +385,27 @@ export const ExcelUploadModal: React.FC = ({ }, [open]); return ( - - + - - + + 엑셀 데이터 업로드 - - + + 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다. - - + + {/* 스텝 인디케이터 */}
@@ -863,7 +863,7 @@ export const ExcelUploadModal: React.FC = ({ )}
- + )} - -
-
+ + + ); }; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index a048bbe4..4da781e6 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -2,12 +2,12 @@ import React, { useState, useEffect, useRef } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; @@ -514,16 +514,18 @@ export const ScreenModal: React.FC = ({ className }) => { } // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 - const headerHeight = 60; // DialogHeader (타이틀 + 패딩) + // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding + const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3) const footerHeight = 52; // 연속 등록 모드 체크박스 영역 + const dialogGap = 16; // DialogContent gap-4 + const extraPadding = 24; // 추가 여백 (안전 마진) - const totalHeight = screenDimensions.height + headerHeight + footerHeight; + const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding; return { className: "overflow-hidden p-0", style: { - width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가 height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, maxWidth: "98vw", maxHeight: "95vh", @@ -593,36 +595,28 @@ export const ScreenModal: React.FC = ({ className }) => { ]); return ( - - + - +
- {modalState.title} + {modalState.title} {modalState.description && !loading && ( - + {modalState.description} - + )} {loading && ( - + {loading ? "화면을 불러오는 중입니다..." : ""} - + )}
-
+ -
+
{loading ? (
@@ -728,8 +722,8 @@ export const ScreenModal: React.FC = ({ className }) => {
- - + + ); }; diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index 033c18ac..f2970b4f 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -7,12 +7,12 @@ import React, { useEffect, useState } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -209,7 +209,7 @@ export function TableHistoryModal({ - + 변경 이력{" "} {!recordId && ( @@ -217,12 +217,12 @@ export function TableHistoryModal({ 전체 )} - - + + {recordId ? `${recordDisplayValue || recordLabel || "-"} - ${tableName} 테이블` : `${tableName} 테이블 전체 이력`} - + {loading ? ( diff --git a/frontend/components/common/TableOptionsModal.tsx b/frontend/components/common/TableOptionsModal.tsx index f19a1a07..64b2b02d 100644 --- a/frontend/components/common/TableOptionsModal.tsx +++ b/frontend/components/common/TableOptionsModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -150,23 +150,14 @@ export function TableOptionsModal({ }; return ( - - - - 테이블 옵션 - - 컬럼 표시/숨기기, 순서 변경, 틀고정 등을 설정할 수 있습니다. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다. - - + + + + 테이블 옵션 + + 컬럼 표시/숨기기, 순서 변경, 틀고정 등을 설정할 수 있습니다. + + @@ -303,7 +294,7 @@ export function TableOptionsModal({ - + - - - + + + ); } diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 450509ee..9b6482a4 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect, useCallback } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -673,14 +673,14 @@ export const ConnectionSetupModal: React.FC = ({ return ( <> - - - - + + + + 필드 연결 설정 - - + +
{/* 기본 연결 설정 */} @@ -719,16 +719,16 @@ export const ConnectionSetupModal: React.FC = ({ {renderConnectionTypeSettings()}
- + - -
-
+ + + diff --git a/frontend/components/dataflow/SaveDiagramModal.tsx b/frontend/components/dataflow/SaveDiagramModal.tsx index 70a12e39..af7f004f 100644 --- a/frontend/components/dataflow/SaveDiagramModal.tsx +++ b/frontend/components/dataflow/SaveDiagramModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -133,11 +133,11 @@ const SaveDiagramModal: React.FC = ({ return ( <> - - - - 📊 관계도 저장 - + + + + 📊 관계도 저장 +
{/* 관계도 이름 입력 */} @@ -203,7 +203,7 @@ const SaveDiagramModal: React.FC = ({ 관계 목록 -
+
{relationships.map((relationship, index) => (
= ({ )}
- + @@ -260,9 +260,9 @@ const SaveDiagramModal: React.FC = ({ "저장하기" )} - - - + + +
{/* 저장 성공 알림 모달 */} diff --git a/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx b/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx index 7cdd28fd..d5cc9b18 100644 --- a/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx +++ b/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from "react"; import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows"; diff --git a/frontend/components/flow/FlowDataListModal.tsx b/frontend/components/flow/FlowDataListModal.tsx index 61264ffb..352860e5 100644 --- a/frontend/components/flow/FlowDataListModal.tsx +++ b/frontend/components/flow/FlowDataListModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; @@ -130,11 +130,11 @@ export function FlowDataListModal({ - + {stepName} {data.length}건 - - 이 단계에 해당하는 데이터 목록입니다 + + 이 단계에 해당하는 데이터 목록입니다
diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx index e79d3357..ad23acb4 100644 --- a/frontend/components/layout/ProfileModal.tsx +++ b/frontend/components/layout/ProfileModal.tsx @@ -1,11 +1,11 @@ import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -48,11 +48,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod }; return ( - - - - {title} - + + + + {title} +

{message}

@@ -61,8 +61,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod 확인
-
-
+ + ); } @@ -165,11 +165,11 @@ export function ProfileModal({ }; return ( <> - - - - 프로필 수정 - + + + + 프로필 수정 +
{/* 프로필 사진 섹션 */} @@ -449,16 +449,16 @@ export function ProfileModal({ )}
- + - -
-
+ + + {/* 알림 모달 */} - - - 새 차량 등록 - + + + + 새 차량 등록 + 새로운 차량 정보를 입력해주세요. - - + +
@@ -501,16 +501,16 @@ export function ProfileModal({
- + - -
- + + + )} ); diff --git a/frontend/components/mail/MailDetailModal.tsx b/frontend/components/mail/MailDetailModal.tsx index e945bbd9..0a25c2a3 100644 --- a/frontend/components/mail/MailDetailModal.tsx +++ b/frontend/components/mail/MailDetailModal.tsx @@ -6,7 +6,7 @@ import { DialogContent, DialogHeader, -} from "@/components/ui/resizable-dialog"; +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -186,13 +186,13 @@ export default function MailDetailModal({ }; return ( - - - - + + + + 메일 상세 - - + + {loading ? (
@@ -375,8 +375,8 @@ export default function MailDetailModal({
) : null} - - + + ); } diff --git a/frontend/components/multilang/LangKeyModal.tsx b/frontend/components/multilang/LangKeyModal.tsx index c25164f5..06189c58 100644 --- a/frontend/components/multilang/LangKeyModal.tsx +++ b/frontend/components/multilang/LangKeyModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -141,9 +141,9 @@ export function LangKeyModal({ return ( - + - {langKey ? "다국어 키 수정" : "새 다국어 키 추가"} + {langKey ? "다국어 키 수정" : "새 다국어 키 추가"}
diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx index 615f0426..e47e124f 100644 --- a/frontend/components/order/OrderRegistrationModal.tsx +++ b/frontend/components/order/OrderRegistrationModal.tsx @@ -210,7 +210,7 @@ export function OrderRegistrationModal({ return ( - + 수주 등록 diff --git a/frontend/components/report/ReportCreateModal.tsx b/frontend/components/report/ReportCreateModal.tsx index ef2a325d..c51dd982 100644 --- a/frontend/components/report/ReportCreateModal.tsx +++ b/frontend/components/report/ReportCreateModal.tsx @@ -4,11 +4,10 @@ import { useState, useEffect } from "react"; import { Dialog, DialogContent, - - DialogHeader, - -} from "@/components/ui/resizable-dialog"; + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -120,8 +119,8 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo - 새 리포트 생성 - 새로운 리포트를 생성합니다. 필수 항목을 입력해주세요. + 새 리포트 생성 + 새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.
@@ -207,7 +206,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
- + @@ -221,7 +220,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo "생성" )} - +
); diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index 75493e4f..c37603c5 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -424,7 +424,7 @@ export default function CopyScreenModal({ return ( - + diff --git a/frontend/components/screen/CreateScreenModal.tsx b/frontend/components/screen/CreateScreenModal.tsx index 4f0e5eb9..fc39140d 100644 --- a/frontend/components/screen/CreateScreenModal.tsx +++ b/frontend/components/screen/CreateScreenModal.tsx @@ -2,12 +2,12 @@ import { useEffect, useMemo, useState, useRef } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -271,21 +271,11 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre }; return ( - - - - 새 화면 생성 - + + + + 새 화면 생성 +
@@ -603,15 +593,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre )}
- + - - - + + +
); } diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 024f7ac7..2a3050fc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; @@ -678,14 +678,17 @@ export const EditModal: React.FC = ({ className }) => { } // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 - const headerHeight = 60; // DialogHeader - const totalHeight = screenDimensions.height + headerHeight; + // 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3) + const dialogGap = 16; // DialogContent gap-4 + const extraPadding = 24; // 추가 여백 (안전 마진) + + const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding; return { className: "overflow-hidden p-0", style: { - width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가 height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, maxWidth: "98vw", maxHeight: "95vh", @@ -696,32 +699,24 @@ export const EditModal: React.FC = ({ className }) => { const modalStyle = getModalStyle(); return ( - - + - +
- {modalState.title || "데이터 수정"} + {modalState.title || "데이터 수정"} {modalState.description && !loading && ( - {modalState.description} + {modalState.description} )} {loading && ( - {loading ? "화면을 불러오는 중입니다..." : ""} + {loading ? "화면을 불러오는 중입니다..." : ""} )}
-
+
-
+
{loading ? (
@@ -812,8 +807,8 @@ export const EditModal: React.FC = ({ className }) => {
)}
- - + +
); }; diff --git a/frontend/components/screen/FileAttachmentDetailModal.tsx b/frontend/components/screen/FileAttachmentDetailModal.tsx index 77015589..835f8940 100644 --- a/frontend/components/screen/FileAttachmentDetailModal.tsx +++ b/frontend/components/screen/FileAttachmentDetailModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useCallback, useEffect } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -352,9 +352,9 @@ export const FileAttachmentDetailModal: React.FC
- + 파일 첨부 관리 - {component.label || component.id} - + diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 88d11447..b681aa35 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -2471,7 +2471,7 @@ export const InteractiveDataTable: React.FC = ({ {/* 기존 데이터 추가 모달 (제거 예정 - SaveModal로 대체됨) */} {}}> - + {component.addModalConfig?.title || "새 데이터 추가"} @@ -2517,7 +2517,7 @@ export const InteractiveDataTable: React.FC = ({ {/* 기존 데이터 수정 모달 (제거 예정 - SaveModal로 대체됨) */} {}}> - + 데이터 수정 선택된 데이터를 수정합니다. @@ -2773,7 +2773,7 @@ export const InteractiveDataTable: React.FC = ({ {/* 파일 관리 모달 */} - + diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8e1f1ce3..d9186999 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { CalendarIcon, File, Upload, X } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 3c9d16f5..41983df3 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -3,8 +3,7 @@ import React, { useState, useCallback, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog"; -import { DialogTitle, DialogHeader } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useAuth } from "@/hooks/useAuth"; import { uploadFilesAndCreateData } from "@/lib/api/file"; import { toast } from "sonner"; @@ -776,17 +775,15 @@ export const InteractiveScreenViewerDynamic: React.FC setPopupScreen(null)}> - setPopupScreen(null)}> + {popupScreen.title} @@ -820,8 +817,8 @@ export const InteractiveScreenViewerDynamic: React.FC )} - - + + )} ); diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index 6fd586a8..fddf0bcc 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect, useRef } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -345,26 +345,26 @@ export const MenuAssignmentModal: React.FC = ({ return ( <> - - + + {assignmentSuccess ? ( // 성공 화면 <> - - + +
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"} -
- + + {assignmentMessage.includes("나중에") ? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다." : "화면이 성공적으로 메뉴에 할당되었습니다."} - -
+
+
@@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC = ({
- + - + ) : ( // 기본 할당 화면 <> - - + + 메뉴에 화면 할당 - - + + 저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다. - + {screenInfo && (
@@ -432,7 +432,7 @@ export const MenuAssignmentModal: React.FC = ({ {screenInfo.description &&

{screenInfo.description}

}
)} - +
{/* 메뉴 선택 (검색 기능 포함) */} @@ -550,7 +550,7 @@ export const MenuAssignmentModal: React.FC = ({ )}
- + - + )} - - + +
{/* 화면 교체 확인 대화상자 */} - - - - + + + + 화면 교체 확인 - - 선택한 메뉴에 이미 할당된 화면이 있습니다. - + + 선택한 메뉴에 이미 할당된 화면이 있습니다. +
{/* 기존 화면 목록 */} @@ -628,7 +628,7 @@ export const MenuAssignmentModal: React.FC = ({
- + @@ -652,9 +652,9 @@ export const MenuAssignmentModal: React.FC = ({ )} - - - + +
+ ); }; diff --git a/frontend/components/screen/ResponsivePreviewModal.tsx b/frontend/components/screen/ResponsivePreviewModal.tsx index 3b121e58..1e05a86b 100644 --- a/frontend/components/screen/ResponsivePreviewModal.tsx +++ b/frontend/components/screen/ResponsivePreviewModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, createContext, useContext } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Monitor, Tablet, Smartphone } from "lucide-react"; import { ComponentData } from "@/types/screen"; @@ -76,7 +76,7 @@ export const ResponsivePreviewModal: React.FC = ({ - 반응형 미리보기 + 반응형 미리보기 {/* 디바이스 선택 버튼들 */}
diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index bf8ee9ce..4e158719 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { X, Save, Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -232,22 +232,19 @@ export const SaveModal: React.FC = ({ const dynamicSize = calculateDynamicSize(); return ( - !isSaving && !open && onClose()}> - !isSaving && !open && onClose()}> + - +
- {initialData ? "데이터 수정" : "데이터 등록"} + {initialData ? "데이터 수정" : "데이터 등록"}
-
+
{loading ? ( @@ -376,7 +373,7 @@ export const SaveModal: React.FC = ({
화면에 컴포넌트가 없습니다.
)}
-
-
+ +
); }; diff --git a/frontend/components/screen/templates/DataTableTemplate.tsx b/frontend/components/screen/templates/DataTableTemplate.tsx index d83d2fc1..b24f27c3 100644 --- a/frontend/components/screen/templates/DataTableTemplate.tsx +++ b/frontend/components/screen/templates/DataTableTemplate.tsx @@ -8,7 +8,7 @@ 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 { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, DialogFooter, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 4256e329..5552ade5 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< ) => ( -
+
); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); DialogFooter.displayName = "DialogFooter"; diff --git a/frontend/components/ui/resizable-dialog.tsx b/frontend/components/ui/resizable-dialog.tsx deleted file mode 100644 index 54d18ed7..00000000 --- a/frontend/components/ui/resizable-dialog.tsx +++ /dev/null @@ -1,601 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { X } from "lucide-react"; -import { cn } from "@/lib/utils"; - -// 🆕 Context를 사용하여 open 상태 공유 -const ResizableDialogContext = React.createContext<{ open: boolean }>({ open: false }); - -// 🆕 ResizableDialog를 래핑하여 Context 제공 -const ResizableDialog: React.FC> = ({ - children, - open = false, - ...props -}) => { - return ( - - - {children} - - - ); -}; - -const ResizableDialogTrigger = DialogPrimitive.Trigger; - -const ResizableDialogPortal = DialogPrimitive.Portal; - -const ResizableDialogClose = DialogPrimitive.Close; - -const ResizableDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -ResizableDialogOverlay.displayName = DialogPrimitive.Overlay.displayName; - -interface ResizableDialogContentProps - extends React.ComponentPropsWithoutRef { - minWidth?: number; - minHeight?: number; - maxWidth?: number; - maxHeight?: number; - defaultWidth?: number; - defaultHeight?: number; - modalId?: string; // localStorage 저장용 고유 ID - userId?: string; // 사용자별 저장용 - open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달) - disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용) -} - -const ResizableDialogContent = React.forwardRef< - React.ElementRef, - ResizableDialogContentProps ->( - ( - { - className, - children, - minWidth = 400, - minHeight = 300, - maxWidth = 1600, - maxHeight = 1200, - defaultWidth = 600, - defaultHeight = 500, - modalId, - userId = "guest", - open: externalOpen, // 🆕 외부에서 전달받은 open 상태 - disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화 - style: userStyle, - ...props - }, - ref - ) => { - const contentRef = React.useRef(null); - - // 고정된 ID 생성 (한번 생성되면 컴포넌트 생명주기 동안 유지) - const stableIdRef = React.useRef(null); - - if (!stableIdRef.current) { - if (modalId) { - stableIdRef.current = modalId; - // // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId); - } else { - // className 기반 ID 생성 - if (className) { - const hash = className.split('').reduce((acc, char) => { - return ((acc << 5) - acc) + char.charCodeAt(0); - }, 0); - stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; - // console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current }); - } else if (userStyle) { - // userStyle 기반 ID 생성 - const styleStr = JSON.stringify(userStyle); - const hash = styleStr.split('').reduce((acc, char) => { - return ((acc << 5) - acc) + char.charCodeAt(0); - }, 0); - stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; - // console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current }); - } else { - // 기본 ID - stableIdRef.current = 'modal-default'; - // console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)"); - } - } - } - - const effectiveModalId = stableIdRef.current; - - // 실제 렌더링된 크기를 감지하여 초기 크기로 사용 - const getInitialSize = React.useCallback(() => { - if (typeof window === 'undefined') return { width: defaultWidth, height: defaultHeight }; - - // 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용) - if (userStyle) { - const styleWidth = typeof userStyle.width === 'string' - ? parseInt(userStyle.width) - : userStyle.width; - const styleHeight = typeof userStyle.height === 'string' - ? parseInt(userStyle.height) - : userStyle.height; - - if (styleWidth && styleHeight) { - const finalSize = { - width: Math.max(minWidth, Math.min(maxWidth, styleWidth)), - height: Math.max(minHeight, Math.min(maxHeight, styleHeight)), - }; - return finalSize; - } - } - - // 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지) - // if (contentRef.current) { - // const rect = contentRef.current.getBoundingClientRect(); - // if (rect.width > 0 && rect.height > 0) { - // return { - // width: Math.max(minWidth, Math.min(maxWidth, rect.width)), - // height: Math.max(minHeight, Math.min(maxHeight, rect.height)), - // }; - // } - // } - - // 3순위: defaultWidth/defaultHeight 사용 - return { width: defaultWidth, height: defaultHeight }; - }, [defaultWidth, defaultHeight, minWidth, minHeight, maxWidth, maxHeight, userStyle]); - - const [size, setSize] = React.useState(getInitialSize); - const [isResizing, setIsResizing] = React.useState(false); - const [resizeDirection, setResizeDirection] = React.useState(""); - const [isInitialized, setIsInitialized] = React.useState(false); - - // userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시) - React.useEffect(() => { - // 1. localStorage에서 사용자가 리사이징한 크기 확인 - let savedSize: { width: number; height: number; userResized: boolean } | null = null; - - if (effectiveModalId && typeof window !== 'undefined') { - try { - const storageKey = `modal_size_${effectiveModalId}_${userId}`; - const saved = localStorage.getItem(storageKey); - - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.userResized) { - savedSize = { - width: Math.max(minWidth, Math.min(maxWidth, parsed.width)), - height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), - userResized: true, - }; - // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); - } - } - } catch (error) { - console.error("❌ 모달 크기 복원 실패:", error); - } - } - - // 2. 우선순위: 사용자 리사이징 > userStyle > 기본값 - if (savedSize && savedSize.userResized) { - // 사용자가 리사이징한 크기 우선 - setSize({ width: savedSize.width, height: savedSize.height }); - setUserResized(true); - } else if (userStyle && userStyle.width && userStyle.height) { - // 화면관리에서 설정한 크기 - const styleWidth = typeof userStyle.width === 'string' - ? parseInt(userStyle.width) - : userStyle.width; - const styleHeight = typeof userStyle.height === 'string' - ? parseInt(userStyle.height) - : userStyle.height; - - if (styleWidth && styleHeight) { - const newSize = { - width: Math.max(minWidth, Math.min(maxWidth, styleWidth)), - height: Math.max(minHeight, Math.min(maxHeight, styleHeight)), - }; - setSize(newSize); - } - } - }, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]); - const [lastModalId, setLastModalId] = React.useState(null); - const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적 - - // 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open) - const context = React.useContext(ResizableDialogContext); - const actualOpen = externalOpen !== undefined ? externalOpen : context.open; - - // 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋 - const [wasOpen, setWasOpen] = React.useState(false); - - React.useEffect(() => { - // console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId }); - - if (actualOpen && !wasOpen) { - // 모달이 방금 열림 - // console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId }); - setIsInitialized(false); - setWasOpen(true); - } else if (!actualOpen && wasOpen) { - // 모달이 방금 닫힘 - // console.log("🔒 모달 닫힘 감지:", { effectiveModalId }); - setWasOpen(false); - } - }, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]); - - // modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우) - React.useEffect(() => { - if (effectiveModalId !== lastModalId) { - // console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized }); - setIsInitialized(false); - setUserResized(false); // 사용자 리사이징 플래그도 리셋 - setLastModalId(effectiveModalId); - } - }, [effectiveModalId, lastModalId, isInitialized]); - - // 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용) - // 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경 - // React.useEffect(() => { - // // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId }); - // - // if (!isInitialized) { - // // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기) - // // 여러 번 시도하여 contentRef가 준비될 때까지 대기 - // let attempts = 0; - // const maxAttempts = 10; - // - // const measureContent = () => { - // attempts++; - // - // // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함) - // let contentWidth = defaultWidth; - // let contentHeight = defaultHeight; - // - // // if (contentRef.current) { - // // // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거) - // // contentWidth = contentRef.current.scrollWidth || defaultWidth; - // // contentHeight = contentRef.current.scrollHeight || defaultHeight; - // // - // // // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight }); - // // } else { - // // // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight }); - // // - // // // contentRef가 아직 없으면 재시도 - // // if (attempts < maxAttempts) { - // // setTimeout(measureContent, 100); - // // return; - // // } - // // } - // - // // 패딩 추가 (p-6 * 2 = 48px) - // const paddingAndMargin = 48; - // const initialSize = getInitialSize(); - // - // // 내용 크기 기반 최소 크기 계산 - // const contentBasedSize = { - // width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))), - // height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))), - // }; - // - // // console.log("📐 내용 기반 크기:", contentBasedSize); - // - // // localStorage에서 저장된 크기 확인 - // let finalSize = contentBasedSize; - // - // if (effectiveModalId && typeof window !== 'undefined') { - // try { - // const storageKey = `modal_size_${effectiveModalId}_${userId}`; - // const saved = localStorage.getItem(storageKey); - // - // // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" }); - // - // if (saved) { - // const parsed = JSON.parse(saved); - // - // // userResized 플래그 확인 - // if (parsed.userResized) { - // const savedSize = { - // width: Math.max(minWidth, Math.min(maxWidth, parsed.width)), - // height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), - // }; - // - // // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); - // - // // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용 - // // (사용자가 의도적으로 작게 만든 것을 존중) - // finalSize = savedSize; - // setUserResized(true); - // - // // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" }); - // } else { - // // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용"); - // } - // } else { - // // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용"); - // } - // } catch (error) { - // // console.error("❌ 모달 크기 복원 실패:", error); - // } - // } - // - // setSize(finalSize); - // setIsInitialized(true); - // }; - // - // // 첫 시도는 300ms 후에 시작 - // setTimeout(measureContent, 300); - // } - // }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]); - - const startResize = (direction: string) => (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsResizing(true); - setResizeDirection(direction); - - const startX = e.clientX; - const startY = e.clientY; - const startWidth = size.width; - const startHeight = size.height; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = moveEvent.clientX - startX; - const deltaY = moveEvent.clientY - startY; - - let newWidth = startWidth; - let newHeight = startHeight; - - if (direction.includes("e")) { - newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + deltaX)); - } - if (direction.includes("w")) { - newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth - deltaX)); - } - if (direction.includes("s")) { - newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY)); - } - if (direction.includes("n")) { - newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight - deltaY)); - } - - setSize({ width: newWidth, height: newHeight }); - }; - - const handleMouseUp = () => { - setIsResizing(false); - setResizeDirection(""); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - // 사용자가 리사이징했음을 표시 - setUserResized(true); - - // ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기) - if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) { - try { - const storageKey = `modal_size_${effectiveModalId}_${userId}`; - - // contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용 - const modalElement = contentRef.current.parentElement; - const actualWidth = modalElement?.offsetWidth || size.width; - const actualHeight = modalElement?.offsetHeight || size.height; - - const currentSize = { - width: actualWidth, - height: actualHeight, - userResized: true, // 사용자가 직접 리사이징했음을 표시 - }; - localStorage.setItem(storageKey, JSON.stringify(currentSize)); - // console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } }); - } catch (error) { - // console.error("❌ 모달 크기 저장 실패:", error); - } - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; - - return ( - - - -
- {children} -
- - {/* 리사이즈 핸들 */} - {/* 오른쪽 */} -
- {/* 아래 */} -
- {/* 오른쪽 아래 */} -
- {/* 왼쪽 */} -
- {/* 위 */} -
- {/* 왼쪽 아래 */} -
- {/* 오른쪽 위 */} -
- {/* 왼쪽 위 */} -
- - {/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */} - {userResized && ( - - )} - - - - Close - - - - ); - } -); -ResizableDialogContent.displayName = DialogPrimitive.Content.displayName; - -const ResizableDialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -ResizableDialogHeader.displayName = "ResizableDialogHeader"; - -const ResizableDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -ResizableDialogFooter.displayName = "ResizableDialogFooter"; - -const ResizableDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -ResizableDialogTitle.displayName = DialogPrimitive.Title.displayName; - -const ResizableDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -ResizableDialogDescription.displayName = - DialogPrimitive.Description.displayName; - -export { - ResizableDialog, - ResizableDialogPortal, - ResizableDialogOverlay, - ResizableDialogClose, - ResizableDialogTrigger, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogFooter, - ResizableDialogTitle, - ResizableDialogDescription, -}; - diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index ade700e1..ce9d4cf6 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -428,6 +428,31 @@ export const RepeaterInput: React.FC = ({ return {option?.label || value}; } + // 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드) + const mapping = categoryMappings[field.name]; + if (mapping && value) { + const valueStr = String(value); + const categoryData = mapping[valueStr]; + if (categoryData) { + // 색상이 있으면 배지로 표시 + if (categoryData.color && categoryData.color !== "none" && categoryData.color !== "#64748b") { + return ( + + {categoryData.label} + + ); + } + // 색상이 없으면 텍스트로 표시 + return {categoryData.label}; + } + } + // 일반 텍스트 return ( @@ -556,44 +581,40 @@ export const RepeaterInput: React.FC = ({ } }; - // 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드) + // 카테고리 매핑 로드 (카테고리 필드 + readonly 필드에 대해 자동 로드) // 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values useEffect(() => { + // 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성) const categoryFields = fields.filter(f => f.type === "category"); - if (categoryFields.length === 0) return; + const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text"); + + if (categoryFields.length === 0 && readonlyFields.length === 0) return; const loadCategoryMappings = async () => { const apiClient = (await import("@/lib/api/client")).apiClient; + // 1. 카테고리 타입 필드 매핑 로드 for (const field of categoryFields) { - const columnName = field.name; // 실제 컬럼명 - const categoryCode = field.categoryCode || columnName; + const columnName = field.name; - // 이미 로드된 경우 스킵 if (categoryMappings[columnName]) continue; try { - // config에서 targetTable 가져오기, 없으면 스킵 const tableName = config.targetTable; - if (!tableName) { - console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`); - continue; - } + if (!tableName) continue; console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); - // 테이블 리스트와 동일한 API 사용 const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; response.data.data.forEach((item: any) => { - // valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일) const key = String(item.valueCode); mapping[key] = { label: item.valueLabel || key, - color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일) + color: item.color || "#64748b", }; }); @@ -608,6 +629,50 @@ export const RepeaterInput: React.FC = ({ console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error); } } + + // 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드 + // material, division 등 조인된 테이블의 카테고리 필드 + const joinedTableFields = ['material', 'division', 'status', 'currency_code']; + const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name)); + + if (fieldsToLoadFromJoinedTable.length > 0) { + // item_info 테이블에서 카테고리 매핑 로드 + const joinedTableName = 'item_info'; + + for (const field of fieldsToLoadFromJoinedTable) { + const columnName = field.name; + + if (categoryMappings[columnName]) continue; + + try { + console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`); + + const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`); + + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { + const mapping: Record = {}; + + response.data.data.forEach((item: any) => { + const key = String(item.valueCode); + mapping[key] = { + label: item.valueLabel || key, + color: item.color || "#64748b", + }; + }); + + console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); + + setCategoryMappings(prev => ({ + ...prev, + [columnName]: mapping, + })); + } + } catch (error) { + // 카테고리가 없는 필드는 무시 + console.log(`ℹ️ [RepeaterInput] 조인 테이블 카테고리 없음 (${columnName})`); + } + } + } }; loadCategoryMappings(); diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index f8bf39c7..55f6ea25 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -968,7 +968,7 @@ export const CardDisplayComponent: React.FC = ({ {/* 상세보기 모달 */} - + 📋 @@ -1041,7 +1041,7 @@ export const CardDisplayComponent: React.FC = ({ {/* 편집 모달 */} - + ✏️ diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx index 00daed0a..7f841ec3 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx @@ -79,7 +79,7 @@ export function EntitySearchModal({ return ( - + {modalTitle} diff --git a/frontend/lib/registry/components/file-upload/FileViewerModal.tsx b/frontend/lib/registry/components/file-upload/FileViewerModal.tsx index 8ccb93ff..9eb0edeb 100644 --- a/frontend/lib/registry/components/file-upload/FileViewerModal.tsx +++ b/frontend/lib/registry/components/file-upload/FileViewerModal.tsx @@ -491,7 +491,7 @@ export const FileViewerModal: React.FC = ({ file, isOpen, return ( {}}> - +
@@ -506,7 +506,7 @@ export const FileViewerModal: React.FC = ({ file, isOpen, -
{renderPreview()}
+
{renderPreview()}
{/* 파일 정보 및 액션 버튼 */}
diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index 60da98f8..456594c2 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -166,7 +166,7 @@ export function ItemSelectionModal({ return ( - + {modalTitle} @@ -222,8 +222,8 @@ export function ItemSelectionModal({ )} {/* 검색 결과 테이블 */} -
-
+
+
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 4f78ed23..22c26e45 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -969,15 +969,30 @@ export const TableListComponent: React.FC = ({ try { const mappings: Record> = {}; + const apiClient = (await import("@/lib/api/client")).apiClient; for (const columnName of categoryColumns) { try { + // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인 + let targetTable = tableConfig.selectedTable; + let targetColumn = columnName; + + if (columnName.includes(".")) { + const parts = columnName.split("."); + targetTable = parts[0]; // 조인된 테이블명 (예: item_info) + targetColumn = parts[1]; // 실제 컬럼명 (예: material) + console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, { + originalColumn: columnName, + targetTable, + targetColumn, + }); + } + console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, { - url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`, + url: `/table-categories/${targetTable}/${targetColumn}/values`, }); - const apiClient = (await import("@/lib/api/client")).apiClient; - const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); console.log(`📡 [TableList] API 응답 [${columnName}]:`, { success: response.data.success, @@ -1000,6 +1015,7 @@ export const TableListComponent: React.FC = ({ }); if (Object.keys(mapping).length > 0) { + // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, { columnName, @@ -1028,6 +1044,59 @@ export const TableListComponent: React.FC = ({ } } + // 🆕 엔티티 조인 컬럼 중 카테고리 타입이 아니지만 조인된 테이블의 카테고리 필드인 경우도 로드 + // 화면 설정의 columns에서 "테이블명.컬럼명" 형태의 조인 컬럼 추출 + const joinedColumns = tableConfig.columns + ?.filter((col) => col.columnName?.includes(".")) + .map((col) => col.columnName) || []; + + // 알려진 카테고리 필드 목록 (조인된 테이블에서 자주 사용되는 카테고리 컬럼) + const knownCategoryFields = ["material", "division", "status", "currency_code", "inbound_type", "outbound_type"]; + + for (const joinedColumn of joinedColumns) { + // 이미 로드된 컬럼은 스킵 + if (mappings[joinedColumn]) continue; + + const parts = joinedColumn.split("."); + if (parts.length !== 2) continue; + + const joinedTable = parts[0]; + const joinedColumnName = parts[1]; + + // 알려진 카테고리 필드인 경우만 로드 시도 + if (!knownCategoryFields.includes(joinedColumnName)) continue; + + try { + console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${joinedColumn}]:`, { + url: `/table-categories/${joinedTable}/${joinedColumnName}/values`, + }); + + const response = await apiClient.get(`/table-categories/${joinedTable}/${joinedColumnName}/values`); + + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { + const mapping: Record = {}; + + response.data.data.forEach((item: any) => { + const key = String(item.valueCode); + mapping[key] = { + label: item.valueLabel, + color: item.color, + }; + }); + + if (Object.keys(mapping).length > 0) { + mappings[joinedColumn] = mapping; + console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${joinedColumn}]:`, { + mappingCount: Object.keys(mapping).length, + }); + } + } + } catch (error) { + // 조인 테이블 카테고리 로드 실패는 무시 (카테고리가 아닌 필드일 수 있음) + console.log(`ℹ️ [TableList] 조인 테이블 카테고리 없음 (${joinedColumn})`); + } + } + console.log("📊 [TableList] 전체 카테고리 매핑 설정:", { mappingsCount: Object.keys(mappings).length, mappingsKeys: Object.keys(mappings), @@ -1047,7 +1116,7 @@ export const TableListComponent: React.FC = ({ }; loadCategoryMappings(); - }, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns)]); // 더 명확한 의존성 + }, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns)]); // 더 명확한 의존성 // ======================================== // 데이터 가져오기 @@ -1885,7 +1954,18 @@ export const TableListComponent: React.FC = ({ if (inputType === "category") { if (!value) return ""; - const mapping = categoryMappings[column.columnName]; + // 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기 + // 1. 원래 컬럼명 (item_info.material) + // 2. 점(.) 뒤의 컬럼명만 (material) + let mapping = categoryMappings[column.columnName]; + + if (!mapping && column.columnName.includes(".")) { + const simpleColumnName = column.columnName.split(".").pop(); + if (simpleColumnName) { + mapping = categoryMappings[simpleColumnName]; + } + } + const { Badge } = require("@/components/ui/badge"); // 다중 값 처리: 콤마로 구분된 값들을 분리 diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 209b3d2d..17ab3417 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -265,7 +265,7 @@ export const TableListConfigPanel: React.FC = ({ columnName: col.columnName || col.column_name, dataType: col.dataType || col.data_type || "text", label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - })) + })), ); console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개"); } @@ -511,7 +511,7 @@ export const TableListConfigPanel: React.FC = ({ // 🎯 엔티티 컬럼의 표시 컬럼 정보 로드 const loadEntityDisplayConfig = async (column: ColumnConfig) => { const configKey = `${column.columnName}`; - + // 이미 로드된 경우 스킵 if (entityDisplayConfigs[configKey]) return; @@ -609,7 +609,7 @@ export const TableListConfigPanel: React.FC = ({ // 기본 테이블 컬럼 정보는 항상 로드 const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable); const sourceColumns = sourceResult.columns || []; - + // joinTable이 있으면 조인 테이블 컬럼도 로드 let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = []; if (joinTable) { @@ -761,9 +761,7 @@ export const TableListConfigPanel: React.FC = ({ placeholder="테이블 제목 입력..." className="h-8 text-xs" /> -

- 우선순위: 사용자 입력 제목 → 테이블 라벨명 → 테이블명 -

+

우선순위: 사용자 입력 제목 → 테이블 라벨명 → 테이블명

@@ -782,7 +780,7 @@ export const TableListConfigPanel: React.FC = ({ /> - + {config.checkbox?.enabled && ( <>
@@ -793,7 +791,7 @@ export const TableListConfigPanel: React.FC = ({ />
- +
{/* 참조 테이블 미설정 안내 */} - {!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( -
- 현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 테이블의 컬럼도 선택할 수 있습니다. -
- )} + {!column.entityDisplayConfig?.joinTable && + entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( +
+ 현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 + 테이블의 컬럼도 선택할 수 있습니다. +
+ )} {/* 선택된 컬럼 미리보기 */} {entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && ( @@ -1107,7 +1109,7 @@ export const TableListConfigPanel: React.FC = ({ // 해당 컬럼의 input_type 확인 const columnInfo = availableColumns.find((col) => col.columnName === column.columnName); const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal"; - + return (
= ({ {columnInfo?.label || column.displayName || column.columnName} - + {/* 숫자 타입인 경우 천단위 구분자 설정 */} {isNumberType && (
@@ -1131,9 +1133,9 @@ export const TableListConfigPanel: React.FC = ({ }} className="h-3 w-3" /> - @@ -1147,8 +1149,7 @@ export const TableListConfigPanel: React.FC = ({ checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false} onCheckedChange={(checked) => { const currentFilters = config.filter?.filters || []; - const columnLabel = - columnInfo?.label || column.displayName || column.columnName; + const columnLabel = columnInfo?.label || column.displayName || column.columnName; if (checked) { // 필터 추가 @@ -1240,9 +1241,7 @@ export const TableListConfigPanel: React.FC = ({ placeholder="40" className="h-8 text-xs" /> -

- 기본값: 40px (0-200px 범위, 10px 단위 권장) -

+

기본값: 40px (0-200px 범위, 10px 단위 권장)

)} @@ -1251,19 +1250,20 @@ export const TableListConfigPanel: React.FC = ({

데이터 필터링

-

- 특정 컬럼 값으로 데이터를 필터링합니다 -

+

특정 컬럼 값으로 데이터를 필터링합니다


({ - columnName: col.columnName, - columnLabel: col.label || col.columnName, - dataType: col.dataType, - input_type: col.input_type, // 🆕 실제 input_type 전달 - } as any))} + columns={availableColumns.map( + (col) => + ({ + columnName: col.columnName, + columnLabel: col.label || col.columnName, + dataType: col.dataType, + input_type: col.input_type, // 🆕 실제 input_type 전달 + }) as any, + )} config={config.dataFilter} onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} /> @@ -1273,12 +1273,12 @@ export const TableListConfigPanel: React.FC = ({

연결된 필터

-

+

셀렉트박스 등 다른 컴포넌트의 값으로 테이블 데이터를 실시간 필터링합니다


- + {/* 연결된 필터 목록 */}
{(config.linkedFilters || []).map((filter, index) => ( @@ -1293,16 +1293,12 @@ export const TableListConfigPanel: React.FC = ({ newFilters[index] = { ...filter, sourceComponentId: e.target.value }; handleChange("linkedFilters", newFilters); }} - className="h-7 text-xs flex-1" + className="h-7 flex-1 text-xs" /> - + - @@ -1311,7 +1307,7 @@ export const TableListConfigPanel: React.FC = ({ - 컬럼을 찾을 수 없습니다 + 컬럼을 찾을 수 없습니다 {availableColumns.map((col) => ( = ({ {col.label || col.columnName} @@ -1353,7 +1349,7 @@ export const TableListConfigPanel: React.FC = ({
))} - + {/* 연결된 필터 추가 버튼 */} - -

+ +

예: 셀렉트박스(ID: select-basic-123)의 값으로 테이블의 inbound_type 컬럼을 필터링

@@ -1381,12 +1377,12 @@ export const TableListConfigPanel: React.FC = ({

제외 필터

-

+

다른 테이블에 이미 존재하는 데이터를 목록에서 제외합니다


- + {/* 제외 필터 활성화 */}
= ({ 제외 필터 활성화
- + {config.excludeFilter?.enabled && (
{/* 참조 테이블 선택 */} @@ -1411,11 +1407,7 @@ export const TableListConfigPanel: React.FC = ({ - @@ -1424,7 +1416,7 @@ export const TableListConfigPanel: React.FC = ({ - 테이블을 찾을 수 없습니다 + 테이블을 찾을 수 없습니다 {availableTables.map((table) => ( = ({ {table.displayName || table.tableName} @@ -1457,7 +1451,7 @@ export const TableListConfigPanel: React.FC = ({
- + {config.excludeFilter?.referenceTable && ( <> {/* 비교 컬럼 설정 - 한 줄에 두 개 */} @@ -1473,9 +1467,7 @@ export const TableListConfigPanel: React.FC = ({ disabled={loadingReferenceColumns} className="h-8 w-full justify-between text-xs" > - {loadingReferenceColumns - ? "..." - : config.excludeFilter?.referenceColumn || "선택"} + {loadingReferenceColumns ? "..." : config.excludeFilter?.referenceColumn || "선택"} @@ -1483,7 +1475,7 @@ export const TableListConfigPanel: React.FC = ({ - 없음 + 없음 {referenceTableColumns.map((col) => ( = ({ {col.label || col.columnName} @@ -1512,17 +1506,13 @@ export const TableListConfigPanel: React.FC = ({
- + {/* 소스 컬럼 (현재 테이블) */}
- @@ -1531,7 +1521,7 @@ export const TableListConfigPanel: React.FC = ({ - 없음 + 없음 {availableColumns.map((col) => ( = ({ {col.label || col.columnName} @@ -1561,11 +1553,11 @@ export const TableListConfigPanel: React.FC = ({
- + {/* 조건 필터 - 특정 조건의 데이터만 제외 */}
-

+

특정 조건의 데이터만 제외하려면 설정하세요 (예: 특정 거래처의 품목만)

@@ -1578,9 +1570,9 @@ export const TableListConfigPanel: React.FC = ({ disabled={loadingReferenceColumns} className="h-8 w-full justify-between text-xs" > - {loadingReferenceColumns - ? "..." - : config.excludeFilter?.filterColumn + {loadingReferenceColumns + ? "..." + : config.excludeFilter?.filterColumn ? `매핑: ${config.excludeFilter.filterColumn}` : "매핑 테이블 컬럼"} @@ -1590,7 +1582,7 @@ export const TableListConfigPanel: React.FC = ({ - 없음 + 없음 = ({ filterValueField: undefined, }); }} - className="text-xs text-muted-foreground" + className="text-muted-foreground text-xs" > - + 사용 안함 {referenceTableColumns.map((col) => ( @@ -1624,7 +1621,9 @@ export const TableListConfigPanel: React.FC = ({ {col.label || col.columnName} @@ -1635,7 +1634,7 @@ export const TableListConfigPanel: React.FC = ({ - + {/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */} = ({
)} - + {/* 설정 요약 */} - {config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && ( -
- 설정 요약: {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가 - {" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에 - {config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && ( - <> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때) - )} - {" "}이미 있으면 제외 -
- )} + {config.excludeFilter?.referenceTable && + config.excludeFilter?.referenceColumn && + config.excludeFilter?.sourceColumn && ( +
+ 설정 요약: {config.selectedTable || screenTableName}. + {config.excludeFilter.sourceColumn} 가 {config.excludeFilter.referenceTable}. + {config.excludeFilter.referenceColumn} 에 + {config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && ( + <> + {" "} + ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때) + + )}{" "} + 이미 있으면 제외 +
+ )}
)} diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 74d9d0ed..687896c1 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1680,3 +1680,4 @@ const 출고등록_설정: ScreenSplitPanel = { 화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 47526bb1..bc025b41 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -527,3 +527,4 @@ const { data: config } = await getScreenSplitPanel(screenId); 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 135d36d8..cdd94d36 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -514,3 +514,4 @@ function ScreenViewPage() { 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. + From 3f2739a4a5b1eed1b8bc76d1e27346d57afdf823 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 11:04:42 +0900 Subject: [PATCH 02/24] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=83=80=EC=9E=85=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=B0=B0=EC=A7=80=20=ED=91=9C=EC=8B=9C=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 130 +++++++++++++----- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 4 files changed, 101 insertions(+), 32 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 22c26e45..5b397d57 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -365,6 +365,10 @@ export const TableListComponent: React.FC = ({ const [columnMeta, setColumnMeta] = useState< Record >({}); + // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) + const [joinedColumnMeta, setJoinedColumnMeta] = useState< + Record + >({}); const [categoryMappings, setCategoryMappings] = useState< Record> >({}); @@ -1044,58 +1048,119 @@ export const TableListComponent: React.FC = ({ } } - // 🆕 엔티티 조인 컬럼 중 카테고리 타입이 아니지만 조인된 테이블의 카테고리 필드인 경우도 로드 - // 화면 설정의 columns에서 "테이블명.컬럼명" 형태의 조인 컬럼 추출 + // 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드 + // 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출 const joinedColumns = tableConfig.columns ?.filter((col) => col.columnName?.includes(".")) .map((col) => col.columnName) || []; - // 알려진 카테고리 필드 목록 (조인된 테이블에서 자주 사용되는 카테고리 컬럼) - const knownCategoryFields = ["material", "division", "status", "currency_code", "inbound_type", "outbound_type"]; + // 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material) + const additionalJoinColumns = tableConfig.columns + ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) + .map((col: any) => ({ + columnName: col.columnName, // 예: item_code_material + referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info + // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) + actualColumn: col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, '') || col.columnName, + })) || []; + console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns); + + // 조인 테이블별로 그룹화 + const joinedTableColumns: Record = {}; + + // "테이블명.컬럼명" 형태 처리 for (const joinedColumn of joinedColumns) { - // 이미 로드된 컬럼은 스킵 - if (mappings[joinedColumn]) continue; - const parts = joinedColumn.split("."); if (parts.length !== 2) continue; const joinedTable = parts[0]; const joinedColumnName = parts[1]; - // 알려진 카테고리 필드인 경우만 로드 시도 - if (!knownCategoryFields.includes(joinedColumnName)) continue; - + if (!joinedTableColumns[joinedTable]) { + joinedTableColumns[joinedTable] = []; + } + joinedTableColumns[joinedTable].push({ + columnName: joinedColumn, + actualColumn: joinedColumnName, + }); + } + + // additionalJoinInfo 형태 처리 + for (const col of additionalJoinColumns) { + if (!joinedTableColumns[col.referenceTable]) { + joinedTableColumns[col.referenceTable] = []; + } + joinedTableColumns[col.referenceTable].push({ + columnName: col.columnName, // 예: item_code_material + actualColumn: col.actualColumn, // 예: material + }); + } + + console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns); + + // 조인된 테이블별로 inputType 정보 가져오기 + const newJoinedColumnMeta: Record = {}; + + for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) { try { - console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${joinedColumn}]:`, { - url: `/table-categories/${joinedTable}/${joinedColumnName}/values`, - }); + // 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용) + const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable); - const response = await apiClient.get(`/table-categories/${joinedTable}/${joinedColumnName}/values`); + console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes); - if (response.data.success && response.data.data && Array.isArray(response.data.data)) { - const mapping: Record = {}; + for (const col of columns) { + const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn); - response.data.data.forEach((item: any) => { - const key = String(item.valueCode); - mapping[key] = { - label: item.valueLabel, - color: item.color, - }; - }); + // 컬럼명 그대로 저장 (item_code_material 또는 item_info.material) + newJoinedColumnMeta[col.columnName] = { + inputType: inputTypeInfo?.inputType, + }; - if (Object.keys(mapping).length > 0) { - mappings[joinedColumn] = mapping; - console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${joinedColumn}]:`, { - mappingCount: Object.keys(mapping).length, - }); + console.log(` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`); + + // inputType이 category인 경우 카테고리 매핑 로드 + if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { + try { + console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, { + url: `/table-categories/${joinedTable}/${col.actualColumn}/values`, + }); + + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); + + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { + const mapping: Record = {}; + + response.data.data.forEach((item: any) => { + const key = String(item.valueCode); + mapping[key] = { + label: item.valueLabel, + color: item.color, + }; + }); + + if (Object.keys(mapping).length > 0) { + mappings[col.columnName] = mapping; + console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, { + mappingCount: Object.keys(mapping).length, + }); + } + } + } catch (error) { + console.log(`ℹ️ [TableList] 조인 테이블 카테고리 없음 (${col.columnName})`); + } } } } catch (error) { - // 조인 테이블 카테고리 로드 실패는 무시 (카테고리가 아닌 필드일 수 있음) - console.log(`ℹ️ [TableList] 조인 테이블 카테고리 없음 (${joinedColumn})`); + console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error); } } + + // 조인 컬럼 메타데이터 상태 업데이트 + if (Object.keys(newJoinedColumnMeta).length > 0) { + setJoinedColumnMeta(newJoinedColumnMeta); + console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta); + } console.log("📊 [TableList] 전체 카테고리 매핑 설정:", { mappingsCount: Object.keys(mappings).length, @@ -1929,7 +1994,8 @@ export const TableListComponent: React.FC = ({ return rowData.writer_name; } - const meta = columnMeta[column.columnName]; + // 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기 + const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName]; // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; @@ -2119,7 +2185,7 @@ export const TableListComponent: React.FC = ({ return String(value); } }, - [columnMeta, optimizedConvertCode, categoryMappings], + [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings], ); // ======================================== diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 687896c1..313a7567 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1681,3 +1681,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index bc025b41..373b6ec7 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -528,3 +528,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index cdd94d36..5d315706 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -515,3 +515,4 @@ function ScreenViewPage() { + From 09fee581860f257980a07bea3bbad95878e4e83e Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 14:08:07 +0900 Subject: [PATCH 03/24] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B6=84=ED=95=A0?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=9E=90=EB=8F=99=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=84=98=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen-embedding/EmbeddedScreen.tsx | 7 ++++++ .../screen-embedding/ScreenSplitPanel.tsx | 1 + .../screen/InteractiveScreenViewerDynamic.tsx | 6 +++-- frontend/contexts/SplitPanelContext.tsx | 8 +++++++ .../card-display/CardDisplayComponent.tsx | 3 ++- .../ScreenSplitPanelConfigPanel.tsx | 22 +++++++++++++++++++ .../table-list/TableListComponent.tsx | 3 ++- 7 files changed, 46 insertions(+), 4 deletions(-) diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index 3880fc54..d8e62c00 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -92,10 +92,17 @@ export const EmbeddedScreen = forwardRef { // 우측 화면인 경우에만 적용 if (position !== "right" || !splitPanelContext) return; + // 자동 데이터 전달이 비활성화된 경우 스킵 + if (splitPanelContext.disableAutoDataTransfer) { + console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달"); + return; + } + const mappedData = splitPanelContext.getMappedParentData(); if (Object.keys(mappedData).length > 0) { console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index 60b6bf24..1b0eeb65 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -128,6 +128,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp rightScreenId={config?.rightScreenId || null} parentDataMapping={config?.parentDataMapping || []} linkedFilters={config?.linkedFilters || []} + disableAutoDataTransfer={config?.disableAutoDataTransfer ?? false} >
{/* 좌측 패널 */} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 41983df3..97dc0734 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -118,17 +118,19 @@ export const InteractiveScreenViewerDynamic: React.FC>({}); // 🆕 분할 패널에서 매핑된 부모 데이터 가져오기 + // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) const splitPanelMappedData = React.useMemo(() => { - if (splitPanelContext) { + if (splitPanelContext && !splitPanelContext.disableAutoDataTransfer) { return splitPanelContext.getMappedParentData(); } return {}; - }, [splitPanelContext, splitPanelContext?.selectedLeftData]); + }, [splitPanelContext, splitPanelContext?.selectedLeftData, splitPanelContext?.disableAutoDataTransfer]); // formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합) const formData = React.useMemo(() => { const baseData = externalFormData || localFormData; // 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만) + // disableAutoDataTransfer가 true이면 자동 병합 안함 if (Object.keys(splitPanelMappedData).length > 0) { const merged = { ...baseData }; for (const [key, value] of Object.entries(splitPanelMappedData)) { diff --git a/frontend/contexts/SplitPanelContext.tsx b/frontend/contexts/SplitPanelContext.tsx index 99cccdd8..20a0be00 100644 --- a/frontend/contexts/SplitPanelContext.tsx +++ b/frontend/contexts/SplitPanelContext.tsx @@ -88,6 +88,9 @@ interface SplitPanelContextValue { // 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용) getLinkedFilterValues: () => Record; + + // 🆕 자동 데이터 전달 비활성화 여부 (버튼 클릭 시에만 데이터 전달) + disableAutoDataTransfer: boolean; } const SplitPanelContext = createContext(null); @@ -98,6 +101,7 @@ interface SplitPanelProviderProps { rightScreenId: number | null; parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정 linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정 + disableAutoDataTransfer?: boolean; // 🆕 자동 데이터 전달 비활성화 (버튼 클릭 시에만 전달) children: React.ReactNode; } @@ -110,6 +114,7 @@ export function SplitPanelProvider({ rightScreenId, parentDataMapping = [], linkedFilters = [], + disableAutoDataTransfer = false, children, }: SplitPanelProviderProps) { // 좌측/우측 화면의 데이터 수신자 맵 @@ -372,6 +377,8 @@ export function SplitPanelProvider({ // 🆕 연결 필터 관련 linkedFilters, getLinkedFilterValues, + // 🆕 자동 데이터 전달 비활성화 여부 + disableAutoDataTransfer, }), [ splitPanelId, leftScreenId, @@ -391,6 +398,7 @@ export function SplitPanelProvider({ getMappedParentData, linkedFilters, getLinkedFilterValues, + disableAutoDataTransfer, ]); return ( diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 55f6ea25..a3876188 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -345,7 +345,8 @@ export const CardDisplayComponent: React.FC = ({ } // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - if (splitPanelContext && splitPanelPosition === "left") { + // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) + if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (checked) { splitPanelContext.setSelectedLeftData(data); console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", { diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx index b8a1d3dc..0c167ab5 100644 --- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx @@ -53,6 +53,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl buttonPosition: config.buttonPosition || "center", parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[], linkedFilters: config.linkedFilters || [] as LinkedFilter[], + disableAutoDataTransfer: config.disableAutoDataTransfer ?? false, // 자동 데이터 전달 비활성화 ...config, }); @@ -69,6 +70,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl buttonPosition: config.buttonPosition || "center", parentDataMapping: config.parentDataMapping || [], linkedFilters: config.linkedFilters || [], + disableAutoDataTransfer: config.disableAutoDataTransfer ?? false, ...config, }); }, [config]); @@ -386,6 +388,26 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl onCheckedChange={(checked) => updateConfig("resizable", checked)} />
+ + + +
+
+ +

+ 좌측 행 선택 시 우측으로 데이터가 자동 전달되지 않습니다. +
+ 버튼 클릭으로만 데이터를 전달하려면 활성화하세요. +

+
+ updateConfig("disableAutoDataTransfer", checked)} + /> +
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 5b397d57..64e6e540 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1764,7 +1764,8 @@ export const TableListComponent: React.FC = ({ handleRowSelection(rowKey, !isCurrentlySelected); // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - if (splitPanelContext && splitPanelPosition === "left") { + // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) + if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (!isCurrentlySelected) { // 선택된 경우: 데이터 저장 splitPanelContext.setSelectedLeftData(row); From 0429e0db7d4e6142b931b2fdfb14399f7092d51d Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 15:18:55 +0900 Subject: [PATCH 04/24] =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=85=B8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/nodeFlowExecutionService.ts | 160 ++++++ .../dataflow/node-editor/FlowEditor.tsx | 2 + .../node-editor/nodes/AggregateNode.tsx | 107 ++++ .../node-editor/panels/PropertiesPanel.tsx | 6 + .../panels/properties/AggregateProperties.tsx | 526 ++++++++++++++++++ .../properties/InsertActionProperties.tsx | 45 +- .../properties/UpdateActionProperties.tsx | 42 +- .../properties/UpsertActionProperties.tsx | 40 +- .../node-editor/sidebar/nodePaletteConfig.ts | 8 + frontend/types/node-editor.ts | 30 + 10 files changed, 959 insertions(+), 7 deletions(-) create mode 100644 frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9cdd85f3..e70a1dae 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -528,6 +528,9 @@ export class NodeFlowExecutionService { case "dataTransform": return this.executeDataTransform(node, inputData, context); + case "aggregate": + return this.executeAggregate(node, inputData, context); + case "insertAction": return this.executeInsertAction(node, inputData, context, client); @@ -3197,4 +3200,161 @@ export class NodeFlowExecutionService { "upsertAction", ].includes(nodeType); } + + /** + * 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등) + */ + private static async executeAggregate( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data; + + logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`); + + // 입력 데이터가 없으면 빈 배열 반환 + if (!inputData || !Array.isArray(inputData) || inputData.length === 0) { + logger.warn("⚠️ 집계할 입력 데이터가 없습니다."); + return []; + } + + logger.info(`📥 입력 데이터: ${inputData.length}건`); + logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`); + logger.info(`📊 집계 연산: ${aggregations.length}개`); + + // 그룹화 수행 + const groups = new Map(); + + for (const row of inputData) { + // 그룹 키 생성 + const groupKey = groupByFields.length > 0 + ? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||") + : "__ALL__"; + + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(row); + } + + logger.info(`📊 그룹 수: ${groups.size}개`); + + // 각 그룹에 대해 집계 수행 + const results: any[] = []; + + for (const [groupKey, groupRows] of groups) { + const resultRow: any = {}; + + // 그룹 기준 필드값 추가 + if (groupByFields.length > 0) { + const keyValues = groupKey.split("|||"); + groupByFields.forEach((field: any, idx: number) => { + resultRow[field.field] = keyValues[idx]; + }); + } + + // 각 집계 연산 수행 + for (const agg of aggregations) { + const { sourceField, function: aggFunc, outputField } = agg; + + if (!outputField) continue; + + let aggregatedValue: any; + + switch (aggFunc) { + case "SUM": + aggregatedValue = groupRows.reduce((sum: number, row: any) => { + const val = parseFloat(row[sourceField]); + return sum + (isNaN(val) ? 0 : val); + }, 0); + break; + + case "COUNT": + aggregatedValue = groupRows.length; + break; + + case "AVG": + const sum = groupRows.reduce((acc: number, row: any) => { + const val = parseFloat(row[sourceField]); + return acc + (isNaN(val) ? 0 : val); + }, 0); + aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0; + break; + + case "MIN": + aggregatedValue = groupRows.reduce((min: number | null, row: any) => { + const val = parseFloat(row[sourceField]); + if (isNaN(val)) return min; + return min === null ? val : Math.min(min, val); + }, null); + break; + + case "MAX": + aggregatedValue = groupRows.reduce((max: number | null, row: any) => { + const val = parseFloat(row[sourceField]); + if (isNaN(val)) return max; + return max === null ? val : Math.max(max, val); + }, null); + break; + + case "FIRST": + aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null; + break; + + case "LAST": + aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null; + break; + + default: + logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`); + aggregatedValue = null; + } + + resultRow[outputField] = aggregatedValue; + logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`); + } + + results.push(resultRow); + } + + // HAVING 조건 적용 (집계 후 필터링) + let filteredResults = results; + if (havingConditions && havingConditions.length > 0) { + filteredResults = results.filter((row) => { + return havingConditions.every((condition: any) => { + const fieldValue = row[condition.field]; + const compareValue = parseFloat(condition.value); + + switch (condition.operator) { + case "=": + return fieldValue === compareValue; + case "!=": + return fieldValue !== compareValue; + case ">": + return fieldValue > compareValue; + case ">=": + return fieldValue >= compareValue; + case "<": + return fieldValue < compareValue; + case "<=": + return fieldValue <= compareValue; + default: + return true; + } + }); + }); + + logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`); + } + + logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`); + + // 결과 샘플 출력 + if (filteredResults.length > 0) { + logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2)); + } + + return filteredResults; + } } diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index c87c80aa..f74d35aa 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -25,6 +25,7 @@ import { UpdateActionNode } from "./nodes/UpdateActionNode"; import { DeleteActionNode } from "./nodes/DeleteActionNode"; import { UpsertActionNode } from "./nodes/UpsertActionNode"; import { DataTransformNode } from "./nodes/DataTransformNode"; +import { AggregateNode } from "./nodes/AggregateNode"; import { RestAPISourceNode } from "./nodes/RestAPISourceNode"; import { CommentNode } from "./nodes/CommentNode"; import { LogNode } from "./nodes/LogNode"; @@ -41,6 +42,7 @@ const nodeTypes = { // 변환/조건 condition: ConditionNode, dataTransform: DataTransformNode, + aggregate: AggregateNode, // 액션 insertAction: InsertActionNode, updateAction: UpdateActionNode, diff --git a/frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx b/frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx new file mode 100644 index 00000000..51ed5371 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx @@ -0,0 +1,107 @@ +"use client"; + +/** + * 집계 노드 (Aggregate Node) + * SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Calculator, Layers } from "lucide-react"; +import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor"; + +// 집계 함수별 아이콘/라벨 +const AGGREGATE_FUNCTION_LABELS: Record = { + SUM: "합계", + COUNT: "개수", + AVG: "평균", + MIN: "최소", + MAX: "최대", + FIRST: "첫번째", + LAST: "마지막", +}; + +export const AggregateNode = memo(({ data, selected }: NodeProps) => { + const groupByCount = data.groupByFields?.length || 0; + const aggregationCount = data.aggregations?.length || 0; + + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || "집계"}
+
+ {groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계 +
+
+
+ + {/* 본문 */} +
+ {/* 그룹 기준 */} + {groupByCount > 0 && ( +
+
+ + 그룹 기준 +
+
+ {data.groupByFields.slice(0, 3).map((field, idx) => ( + + {field.fieldLabel || field.field} + + ))} + {data.groupByFields.length > 3 && ( + +{data.groupByFields.length - 3} + )} +
+
+ )} + + {/* 집계 연산 */} + {aggregationCount > 0 ? ( +
+ {data.aggregations.slice(0, 4).map((agg, idx) => ( +
+
+ + {AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function} + + + {agg.outputFieldLabel || agg.outputField} + +
+
+ {agg.sourceFieldLabel || agg.sourceField} +
+
+ ))} + {data.aggregations.length > 4 && ( +
+ ... 외 {data.aggregations.length - 4}개 +
+ )} +
+ ) : ( +
집계 연산 없음
+ )} +
+ + {/* 핸들 */} + + +
+ ); +}); + +AggregateNode.displayName = "AggregateNode"; + diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index ada62e8d..cf7c7e6e 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -16,6 +16,7 @@ import { DeleteActionProperties } from "./properties/DeleteActionProperties"; import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties"; import { UpsertActionProperties } from "./properties/UpsertActionProperties"; import { DataTransformProperties } from "./properties/DataTransformProperties"; +import { AggregateProperties } from "./properties/AggregateProperties"; import { RestAPISourceProperties } from "./properties/RestAPISourceProperties"; import { CommentProperties } from "./properties/CommentProperties"; import { LogProperties } from "./properties/LogProperties"; @@ -122,6 +123,9 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "dataTransform": return ; + case "aggregate": + return ; + case "restAPISource": return ; @@ -157,9 +161,11 @@ function getNodeTypeLabel(type: NodeType): string { tableSource: "테이블 소스", externalDBSource: "외부 DB 소스", restAPISource: "REST API 소스", + referenceLookup: "참조 조회", condition: "조건 분기", fieldMapping: "필드 매핑", dataTransform: "데이터 변환", + aggregate: "집계", insertAction: "INSERT 액션", updateAction: "UPDATE 액션", deleteAction: "DELETE 액션", diff --git a/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx new file mode 100644 index 00000000..6d3d7311 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx @@ -0,0 +1,526 @@ +"use client"; + +/** + * 집계 노드 속성 편집 패널 + * SUM, COUNT, AVG, MIN, MAX 등 집계 연산 설정 + */ + +import { useEffect, useState, useCallback } from "react"; +import { Plus, Trash2, Calculator, Layers, Filter } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor"; + +interface AggregatePropertiesProps { + nodeId: string; + data: AggregateNodeData; +} + +// 집계 함수 옵션 +const AGGREGATE_FUNCTIONS: Array<{ value: AggregateFunction; label: string; description: string }> = [ + { value: "SUM", label: "합계 (SUM)", description: "숫자 필드의 합계를 계산합니다" }, + { value: "COUNT", label: "개수 (COUNT)", description: "레코드 개수를 계산합니다" }, + { value: "AVG", label: "평균 (AVG)", description: "숫자 필드의 평균을 계산합니다" }, + { value: "MIN", label: "최소 (MIN)", description: "최소값을 찾습니다" }, + { value: "MAX", label: "최대 (MAX)", description: "최대값을 찾습니다" }, + { value: "FIRST", label: "첫번째 (FIRST)", description: "그룹의 첫 번째 값을 가져옵니다" }, + { value: "LAST", label: "마지막 (LAST)", description: "그룹의 마지막 값을 가져옵니다" }, +]; + +// 비교 연산자 옵션 +const OPERATORS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "보다 큼 (>)" }, + { value: ">=", label: "크거나 같음 (>=)" }, + { value: "<", label: "보다 작음 (<)" }, + { value: "<=", label: "작거나 같음 (<=)" }, +]; + +export function AggregateProperties({ nodeId, data }: AggregatePropertiesProps) { + const { updateNode, nodes, edges } = useFlowEditorStore(); + + // 로컬 상태 + const [displayName, setDisplayName] = useState(data.displayName || "집계"); + const [groupByFields, setGroupByFields] = useState(data.groupByFields || []); + const [aggregations, setAggregations] = useState(data.aggregations || []); + const [havingConditions, setHavingConditions] = useState(data.havingConditions || []); + + // 소스 필드 목록 (연결된 입력 노드에서 가져오기) + const [sourceFields, setSourceFields] = useState>([]); + + // 데이터 변경 시 로컬 상태 업데이트 + useEffect(() => { + setDisplayName(data.displayName || "집계"); + setGroupByFields(data.groupByFields || []); + setAggregations(data.aggregations || []); + setHavingConditions(data.havingConditions || []); + }, [data]); + + // 연결된 소스 노드에서 필드 가져오기 + useEffect(() => { + const inputEdges = edges.filter((edge) => edge.target === nodeId); + const sourceNodeIds = inputEdges.map((edge) => edge.source); + const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id)); + + const fields: Array<{ name: string; label?: string; type?: string }> = []; + sourceNodes.forEach((node) => { + if (node.data.fields) { + node.data.fields.forEach((field: any) => { + fields.push({ + name: field.name, + label: field.label || field.displayName, + type: field.type, + }); + }); + } + }); + + setSourceFields(fields); + }, [nodeId, nodes, edges]); + + // 저장 함수 + const saveToNode = useCallback( + (updates: Partial) => { + updateNode(nodeId, { + displayName, + groupByFields, + aggregations, + havingConditions, + ...updates, + }); + }, + [nodeId, updateNode, displayName, groupByFields, aggregations, havingConditions] + ); + + // 그룹 기준 필드 토글 + const handleGroupByToggle = (fieldName: string, checked: boolean) => { + let newGroupByFields; + if (checked) { + const field = sourceFields.find((f) => f.name === fieldName); + newGroupByFields = [...groupByFields, { field: fieldName, fieldLabel: field?.label }]; + } else { + newGroupByFields = groupByFields.filter((f) => f.field !== fieldName); + } + setGroupByFields(newGroupByFields); + saveToNode({ groupByFields: newGroupByFields }); + }; + + // 집계 연산 추가 + const handleAddAggregation = () => { + const newAggregation = { + id: `agg_${Date.now()}`, + sourceField: "", + sourceFieldLabel: "", + function: "SUM" as AggregateFunction, + outputField: "", + outputFieldLabel: "", + }; + const newAggregations = [...aggregations, newAggregation]; + setAggregations(newAggregations); + saveToNode({ aggregations: newAggregations }); + }; + + // 집계 연산 삭제 + const handleRemoveAggregation = (index: number) => { + const newAggregations = aggregations.filter((_, i) => i !== index); + setAggregations(newAggregations); + saveToNode({ aggregations: newAggregations }); + }; + + // 집계 연산 변경 + const handleAggregationChange = (index: number, field: string, value: any) => { + const newAggregations = [...aggregations]; + + if (field === "sourceField") { + const sourceField = sourceFields.find((f) => f.name === value); + newAggregations[index] = { + ...newAggregations[index], + sourceField: value, + sourceFieldLabel: sourceField?.label, + // 출력 필드명 자동 생성 (예: sum_amount) + outputField: + newAggregations[index].outputField || + `${newAggregations[index].function.toLowerCase()}_${value}`, + }; + } else if (field === "function") { + newAggregations[index] = { + ...newAggregations[index], + function: value, + // 출력 필드명 업데이트 + outputField: newAggregations[index].sourceField + ? `${value.toLowerCase()}_${newAggregations[index].sourceField}` + : newAggregations[index].outputField, + }; + } else { + newAggregations[index] = { ...newAggregations[index], [field]: value }; + } + + setAggregations(newAggregations); + saveToNode({ aggregations: newAggregations }); + }; + + // HAVING 조건 추가 + const handleAddHavingCondition = () => { + const newCondition = { + field: "", + operator: "=", + value: "", + }; + const newConditions = [...havingConditions, newCondition]; + setHavingConditions(newConditions); + saveToNode({ havingConditions: newConditions }); + }; + + // HAVING 조건 삭제 + const handleRemoveHavingCondition = (index: number) => { + const newConditions = havingConditions.filter((_, i) => i !== index); + setHavingConditions(newConditions); + saveToNode({ havingConditions: newConditions }); + }; + + // HAVING 조건 변경 + const handleHavingConditionChange = (index: number, field: string, value: any) => { + const newConditions = [...havingConditions]; + newConditions[index] = { ...newConditions[index], [field]: value }; + setHavingConditions(newConditions); + saveToNode({ havingConditions: newConditions }); + }; + + // 집계 결과 필드 목록 (HAVING 조건에서 선택용) + const aggregatedFields = aggregations + .filter((agg) => agg.outputField) + .map((agg) => ({ + name: agg.outputField, + label: agg.outputFieldLabel || agg.outputField, + })); + + return ( +
+
+ {/* 헤더 */} +
+ + 집계 노드 +
+ + {/* 기본 정보 */} +
+

기본 정보

+
+ + { + setDisplayName(e.target.value); + saveToNode({ displayName: e.target.value }); + }} + className="mt-1" + placeholder="노드 표시 이름" + /> +
+
+ + {/* 그룹 기준 필드 */} +
+
+ +

그룹 기준 필드

+
+

+ 선택한 필드를 기준으로 데이터를 그룹화합니다. 선택하지 않으면 전체 데이터를 하나의 그룹으로 처리합니다. +

+ + {sourceFields.length === 0 ? ( +
+ 연결된 소스 노드가 없습니다 +
+ ) : ( +
+
+ {sourceFields.map((field) => { + const isChecked = groupByFields.some((f) => f.field === field.name); + return ( +
+ handleGroupByToggle(field.name, checked as boolean)} + /> + +
+ ); + })} +
+
+ )} + + {groupByFields.length > 0 && ( +
+ {groupByFields.map((field) => ( + + {field.fieldLabel || field.field} + + ))} +
+ )} +
+ + {/* 집계 연산 */} +
+
+
+ +

집계 연산

+
+ +
+

SUM, COUNT, AVG 등 집계 연산을 설정합니다.

+ + {aggregations.length === 0 ? ( +
+ 집계 연산을 추가하세요 +
+ ) : ( +
+ {aggregations.map((agg, index) => ( +
+
+ 집계 #{index + 1} + +
+ +
+ {/* 집계 함수 선택 */} +
+ + +
+ + {/* 소스 필드 선택 */} +
+ + +
+ + {/* 출력 필드명 */} +
+ + handleAggregationChange(index, "outputField", e.target.value)} + placeholder="예: total_amount" + className="mt-1 h-8 text-xs" + /> +

+ 집계 결과가 저장될 필드명입니다 +

+
+ + {/* 출력 필드 라벨 */} +
+ + handleAggregationChange(index, "outputFieldLabel", e.target.value)} + placeholder="예: 총 금액" + className="mt-1 h-8 text-xs" + /> +
+
+
+ ))} +
+ )} +
+ + {/* HAVING 조건 (선택) */} +
+
+
+ +

집계 후 필터 (HAVING)

+
+ +
+

집계 결과에 대한 필터링 조건을 설정합니다 (선택 사항).

+ + {havingConditions.length === 0 ? ( +
+ 집계 후 필터링이 필요하면 조건을 추가하세요 +
+ ) : ( +
+ {havingConditions.map((condition, index) => ( +
+ {/* 집계 결과 필드 선택 */} + + + {/* 연산자 선택 */} + + + {/* 비교값 */} + handleHavingConditionChange(index, "value", e.target.value)} + placeholder="값" + className="h-8 flex-1 text-xs" + /> + + {/* 삭제 버튼 */} + +
+ ))} +
+ )} +
+ + {/* 미리보기 */} + {(groupByFields.length > 0 || aggregations.length > 0) && ( +
+

집계 결과 미리보기

+
+
+ 그룹 기준:{" "} + {groupByFields.length > 0 + ? groupByFields.map((f) => f.fieldLabel || f.field).join(", ") + : "전체 (그룹 없음)"} +
+
+ 집계 컬럼:{" "} + {aggregations.length > 0 + ? aggregations + .filter((a) => a.outputField) + .map((a) => `${a.function}(${a.sourceFieldLabel || a.sourceField}) → ${a.outputFieldLabel || a.outputField}`) + .join(", ") + : "없음"} +
+
+
+ )} +
+
+ ); +} + diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 5f3b3220..465a88fd 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -236,7 +236,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP console.log("⚠️ REST API 노드에 responseFields 없음"); } } - // 3️⃣ 테이블/외부DB 소스 노드 + // 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드 + else if (node.type === "aggregate") { + console.log("✅ 집계 노드 발견"); + const nodeData = node.data as any; + + // 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원) + if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) { + console.log(` 📊 ${nodeData.groupByFields.length}개 그룹 필드 발견`); + nodeData.groupByFields.forEach((groupField: any) => { + const fieldName = groupField.field || groupField.fieldName; + if (fieldName) { + fields.push({ + name: fieldName, + label: groupField.fieldLabel || fieldName, + sourcePath: currentPath, + }); + } + }); + } + + // 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원) + const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || []; + if (Array.isArray(aggregations)) { + console.log(` 📊 ${aggregations.length}개 집계 함수 발견`); + aggregations.forEach((aggFunc: any) => { + // outputField 또는 targetField 둘 다 지원 + const outputFieldName = aggFunc.outputField || aggFunc.targetField; + // function 또는 aggregateType 둘 다 지원 + const funcType = aggFunc.function || aggFunc.aggregateType; + if (outputFieldName) { + fields.push({ + name: outputFieldName, + label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`, + sourcePath: currentPath, + }); + } + }); + } + + // 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달) + } + // 4️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id; @@ -266,7 +307,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`); const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 7d6d2e5a..6d109d5b 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -212,7 +212,43 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP fields.push(...upperFields); } } - // 2️⃣ REST API 소스 노드 + // 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드 + else if (node.type === "aggregate") { + const nodeData = node.data as any; + + // 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원) + if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) { + nodeData.groupByFields.forEach((groupField: any) => { + const fieldName = groupField.field || groupField.fieldName; + if (fieldName) { + fields.push({ + name: fieldName, + label: groupField.fieldLabel || fieldName, + }); + } + }); + } + + // 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원) + const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || []; + if (Array.isArray(aggregations)) { + aggregations.forEach((aggFunc: any) => { + // outputField 또는 targetField 둘 다 지원 + const outputFieldName = aggFunc.outputField || aggFunc.targetField; + // function 또는 aggregateType 둘 다 지원 + const funcType = aggFunc.function || aggFunc.aggregateType; + if (outputFieldName) { + fields.push({ + name: outputFieldName, + label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`, + }); + } + }); + } + + // 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달) + } + // 3️⃣ REST API 소스 노드 else if (node.type === "restAPISource") { foundRestAPI = true; const responseFields = (node.data as any).responseFields; @@ -229,7 +265,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP }); } } - // 3️⃣ 테이블/외부DB 소스 노드 + // 4️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; @@ -251,7 +287,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index 50a53603..57d5d4f2 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -212,7 +212,43 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP }); } } - // 3️⃣ 테이블/외부DB 소스 노드 + // 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드 + else if (node.type === "aggregate") { + const nodeData = node.data as any; + + // 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원) + if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) { + nodeData.groupByFields.forEach((groupField: any) => { + const fieldName = groupField.field || groupField.fieldName; + if (fieldName) { + fields.push({ + name: fieldName, + label: groupField.fieldLabel || fieldName, + }); + } + }); + } + + // 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원) + const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || []; + if (Array.isArray(aggregations)) { + aggregations.forEach((aggFunc: any) => { + // outputField 또는 targetField 둘 다 지원 + const outputFieldName = aggFunc.outputField || aggFunc.targetField; + // function 또는 aggregateType 둘 다 지원 + const funcType = aggFunc.function || aggFunc.aggregateType; + if (outputFieldName) { + fields.push({ + name: outputFieldName, + label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`, + }); + } + }); + } + + // 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달) + } + // 4️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; @@ -234,7 +270,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 97a5b19e..2ff31689 100644 --- a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts +++ b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts @@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [ category: "transform", color: "#06B6D4", // 청록색 }, + { + type: "aggregate", + label: "집계", + icon: "", + description: "SUM, COUNT, AVG 등 집계 연산을 수행합니다", + category: "transform", + color: "#A855F7", // 보라색 + }, // ======================================================================== // 액션 diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index 8959a691..fc5adb89 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -15,6 +15,7 @@ export type NodeType = | "referenceLookup" // 참조 테이블 조회 (내부 DB 전용) | "condition" // 조건 분기 | "dataTransform" // 데이터 변환 + | "aggregate" // 집계 노드 (SUM, COUNT, AVG 등) | "insertAction" // INSERT 액션 | "updateAction" // UPDATE 액션 | "deleteAction" // DELETE 액션 @@ -194,6 +195,34 @@ export interface DataTransformNodeData { displayName?: string; } +// 집계 함수 타입 +export type AggregateFunction = "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "FIRST" | "LAST"; + +// 집계 노드 데이터 +export interface AggregateNodeData { + displayName?: string; + // 그룹 기준 컬럼들 + groupByFields: Array<{ + field: string; // 컬럼명 + fieldLabel?: string; // 라벨 + }>; + // 집계 연산들 + aggregations: Array<{ + id: string; // 고유 ID + sourceField: string; // 집계할 소스 필드 + sourceFieldLabel?: string; // 소스 필드 라벨 + function: AggregateFunction; // 집계 함수 + outputField: string; // 출력 필드명 + outputFieldLabel?: string; // 출력 필드 라벨 + }>; + // 집계 후 필터링 (HAVING 절) + havingConditions?: Array<{ + field: string; // 집계 결과 필드 + operator: string; // 비교 연산자 + value: any; // 비교값 + }>; +} + // INSERT 액션 노드 export interface InsertActionNodeData { displayName?: string; @@ -406,6 +435,7 @@ export type NodeData = | ConditionNodeData | FieldMappingNodeData | DataTransformNodeData + | AggregateNodeData | InsertActionNodeData | UpdateActionNodeData | DeleteActionNodeData From 07fe4baf9f72d4d7b658d36a64e62de4ea1a7ce0 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 10:13:59 +0900 Subject: [PATCH 05/24] =?UTF-8?q?fix(modal-repeater-table):=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=ED=95=84=EB=93=9C=20ISO=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EC=9C=BC=EB=A1=9C=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=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 - RepeaterTable에서 DB 조회된 ISO 형식 날짜를 yyyy-mm-dd로 변환 - formatDateValue 함수 추가: ISO 문자열, Date 객체, 기존 형식 모두 처리 - 수주일(order_date), 납기일(item_due_date) 등 날짜 필드 정상 표시 --- .../modal-repeater-table/RepeaterTable.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index c9aa2cfd..703256b2 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -75,10 +75,28 @@ export function RepeaterTable({ ); case "date": + // ISO 형식(2025-11-23T00:00:00.000Z)을 yyyy-mm-dd로 변환 + const formatDateValue = (val: any): string => { + if (!val) return ""; + // 이미 yyyy-mm-dd 형식이면 그대로 반환 + if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) { + return val; + } + // ISO 형식이면 날짜 부분만 추출 + if (typeof val === "string" && val.includes("T")) { + return val.split("T")[0]; + } + // Date 객체이면 변환 + if (val instanceof Date) { + return val.toISOString().split("T")[0]; + } + return String(val); + }; + return ( handleCellEdit(rowIndex, column.field, e.target.value)} className="h-7 text-xs" /> From c87094c4b3099cfc8b10f18ee38a283a3fac9659 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 17:28:44 +0900 Subject: [PATCH 06/24] =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/dynamicFormService.ts | 18 ++++ .../src/services/nodeFlowExecutionService.ts | 101 +++++++++++------- .../components/webtypes/RepeaterInput.tsx | 14 ++- .../RepeaterFieldGroupRenderer.tsx | 28 +++-- 4 files changed, 110 insertions(+), 51 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 04586d65..d52c184f 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -506,6 +506,24 @@ export class DynamicFormService { // 헤더 + 품목을 병합 const rawMergedData = { ...dataToInsert, ...item }; + // 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함 + // _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE) + // 그 외의 경우는 모두 새 레코드로 처리 (INSERT) + const isExistingRecord = rawMergedData._existingRecord === true; + + if (!isExistingRecord) { + // 새 레코드: id 제거하여 새 UUID 자동 생성 + const oldId = rawMergedData.id; + delete rawMergedData.id; + console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`); + } else { + console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`); + } + + // 메타 플래그 제거 + delete rawMergedData._isNewItem; + delete rawMergedData._existingRecord; + // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) const validColumnNames = columnInfo.map((col) => col.column_name); const mergedData: Record = {}; diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index e70a1dae..7b5f6918 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -833,11 +833,18 @@ export class NodeFlowExecutionService { const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`; + logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`); + const result = await query(sql, whereResult.values); logger.info( `📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건` ); + + // 디버깅: 조회된 데이터 샘플 출력 + if (result.length > 0) { + logger.info(`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`); + } return result; } @@ -1358,57 +1365,64 @@ export class NodeFlowExecutionService { let updatedCount = 0; const updatedDataArray: any[] = []; - // 🆕 table-all 모드: 단일 SQL로 일괄 업데이트 + // 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영) if (context.currentNodeDataSourceType === "table-all") { - console.log("🚀 table-all 모드: 단일 SQL로 일괄 업데이트 시작"); + console.log("🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + dataArray.length + "개 그룹)"); - // 첫 번째 데이터를 참조하여 SET 절 생성 - const firstData = dataArray[0]; - const setClauses: string[] = []; - const values: any[] = []; - let paramIndex = 1; + // 🔥 각 그룹(데이터)별로 UPDATE 실행 + for (let i = 0; i < dataArray.length; i++) { + const data = dataArray[i]; + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; - console.log("🗺️ 필드 매핑 처리 중..."); - fieldMappings.forEach((mapping: any) => { - const value = - mapping.staticValue !== undefined - ? mapping.staticValue - : firstData[mapping.sourceField]; + console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`); + console.log("🗺️ 필드 매핑 처리 중..."); + + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; - console.log( - ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); + + if (mapping.targetField) { + setClauses.push(`${mapping.targetField} = $${paramIndex}`); + values.push(value); + paramIndex++; + } + }); + + // WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함) + const whereResult = this.buildWhereClause( + whereConditions, + data, + paramIndex ); - if (mapping.targetField) { - setClauses.push(`${mapping.targetField} = $${paramIndex}`); - values.push(value); - paramIndex++; - } - }); + values.push(...whereResult.values); - // WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함) - const whereResult = this.buildWhereClause( - whereConditions, - firstData, - paramIndex - ); + const sql = ` + UPDATE ${targetTable} + SET ${setClauses.join(", ")} + ${whereResult.clause} + `; - values.push(...whereResult.values); + console.log("📝 실행할 SQL:", sql); + console.log("📊 바인딩 값:", values); - const sql = ` - UPDATE ${targetTable} - SET ${setClauses.join(", ")} - ${whereResult.clause} - `; - - console.log("📝 실행할 SQL (일괄 처리):", sql); - console.log("📊 바인딩 값:", values); - - const result = await txClient.query(sql, values); - updatedCount = result.rowCount || 0; + const result = await txClient.query(sql, values); + const rowCount = result.rowCount || 0; + updatedCount += rowCount; + + console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}건`); + } logger.info( - `✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}건` + `✅ UPDATE 완료 (내부 DB, 그룹별 처리): ${targetTable}, 총 ${updatedCount}건` ); // 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음) @@ -3216,10 +3230,12 @@ export class NodeFlowExecutionService { // 입력 데이터가 없으면 빈 배열 반환 if (!inputData || !Array.isArray(inputData) || inputData.length === 0) { logger.warn("⚠️ 집계할 입력 데이터가 없습니다."); + logger.warn(`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`); return []; } logger.info(`📥 입력 데이터: ${inputData.length}건`); + logger.info(`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`); logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`); logger.info(`📊 집계 연산: ${aggregations.length}개`); @@ -3239,6 +3255,11 @@ export class NodeFlowExecutionService { } logger.info(`📊 그룹 수: ${groups.size}개`); + + // 디버깅: 각 그룹의 데이터 출력 + for (const [groupKey, groupRows] of groups) { + logger.info(`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`); + } // 각 그룹에 대해 집계 수행 const results: any[] = []; diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index ce9d4cf6..3116b2c6 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -91,6 +91,8 @@ export const RepeaterInput: React.FC = ({ fields.forEach((field) => { item[field.name] = ""; }); + // 🆕 새 항목임을 표시하는 플래그 추가 (백엔드에서 새 레코드로 처리) + item._isNewItem = true; return item; } @@ -113,6 +115,11 @@ export const RepeaterInput: React.FC = ({ } }); + // 🆕 기존 레코드임을 표시 (id가 있는 경우) + if (updatedItem.id) { + updatedItem._existingRecord = true; + } + return hasChange ? updatedItem : item; }); @@ -125,7 +132,12 @@ export const RepeaterInput: React.FC = ({ : updatedValue; onChange?.(dataWithMeta); } else { - setItems(value); + // 🆕 기존 레코드 플래그 추가 + const valueWithFlag = value.map(item => ({ + ...item, + _existingRecord: !!item.id, + })); + setItems(valueWithFlag); } } }, [value]); diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index c47ff3c9..12219280 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -135,6 +135,7 @@ const RepeaterFieldGroupComponent: React.FC = (props) => ...item, _targetTable: targetTable, _originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달 + _existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드) })); onChange(dataWithMeta); } @@ -228,17 +229,23 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지 const definedFields = configRef.current.fields || []; const definedFieldNames = new Set(definedFields.map((f: any) => f.name)); - // 시스템 필드 및 필수 필드 추가 - const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']); + // 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해) + const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']); const filteredData = normalizedData.map((item: any) => { const filteredItem: Record = {}; Object.keys(item).forEach(key => { + // 🆕 id 필드는 제외 (새 레코드로 저장되도록) + if (key === 'id') { + return; // id 필드 제외 + } // 정의된 필드이거나 시스템 필드인 경우만 포함 if (definedFieldNames.has(key) || systemFields.has(key)) { filteredItem[key] = item[key]; } }); + // 🆕 새 항목임을 표시하는 플래그 추가 + filteredItem._isNewItem = true; return filteredItem; }); @@ -259,16 +266,16 @@ const RepeaterFieldGroupComponent: React.FC = (props) => newItems = filteredData; addedCount = filteredData.length; } else { - // 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외 - const existingIds = new Set( + // 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음) + const existingItemCodes = new Set( currentValue - .map((item: any) => item.id || item.po_item_id || item.item_id) + .map((item: any) => item.item_code) .filter(Boolean) ); const uniqueNewItems = filteredData.filter((item: any) => { - const itemId = item.id || item.po_item_id || item.item_id; - if (itemId && existingIds.has(itemId)) { + const itemCode = item.item_code; + if (itemCode && existingItemCodes.has(itemCode)) { duplicateCount++; return false; // 중복 항목 제외 } @@ -291,11 +298,12 @@ const RepeaterFieldGroupComponent: React.FC = (props) => setGroupedData(newItems); // 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용) + // item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음) if (splitPanelContext?.addItemIds && addedCount > 0) { - const newItemIds = newItems - .map((item: any) => String(item.id || item.po_item_id || item.item_id)) + const newItemCodes = newItems + .map((item: any) => String(item.item_code)) .filter(Boolean); - splitPanelContext.addItemIds(newItemIds); + splitPanelContext.addItemIds(newItemCodes); } // JSON 문자열로 변환하여 저장 From 354f006e847120a08d1d3ec26f7e8ae7cd336435 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 17:46:22 +0900 Subject: [PATCH 07/24] =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 5 + backend-node/src/middleware/authMiddleware.ts | 13 +- frontend/lib/api/client.ts | 201 ++++++++++++++++-- 3 files changed, 199 insertions(+), 20 deletions(-) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d214c19a..d36ad8c3 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -8,6 +8,7 @@ import path from "path"; import config from "./config/environment"; import { logger } from "./utils/logger"; import { errorHandler } from "./middleware/errorHandler"; +import { refreshTokenIfNeeded } from "./middleware/authMiddleware"; // 라우터 임포트 import authRoutes from "./routes/authRoutes"; @@ -168,6 +169,10 @@ const limiter = rateLimit({ }); app.use("/api/", limiter); +// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용) +// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함 +app.use("/api/", refreshTokenIfNeeded); + // 헬스 체크 엔드포인트 app.get("/health", (req, res) => { res.status(200).json({ diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index a54c64c6..6d8c7bda 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -54,16 +54,17 @@ export const authenticateToken = ( next(); } catch (error) { - logger.error( - `인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})` - ); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error(`인증 실패: ${errorMessage} (${req.ip})`); + // 토큰 만료 에러인지 확인 + const isTokenExpired = errorMessage.includes("만료"); + res.status(401).json({ success: false, error: { - code: "INVALID_TOKEN", - details: - error instanceof Error ? error.message : "토큰 검증에 실패했습니다.", + code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN", + details: errorMessage || "토큰 검증에 실패했습니다.", }, }); } diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7dc811c9..f4a3ccf7 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -58,6 +58,18 @@ const TokenManager = { return null; }, + setToken: (token: string): void => { + if (typeof window !== "undefined") { + localStorage.setItem("authToken", token); + } + }, + + removeToken: (): void => { + if (typeof window !== "undefined") { + localStorage.removeItem("authToken"); + } + }, + isTokenExpired: (token: string): boolean => { try { const payload = JSON.parse(atob(token.split(".")[1])); @@ -66,8 +78,147 @@ const TokenManager = { return true; } }, + + // 토큰이 곧 만료되는지 확인 (30분 이내) + isTokenExpiringSoon: (token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const expiryTime = payload.exp * 1000; + const currentTime = Date.now(); + const thirtyMinutes = 30 * 60 * 1000; // 30분 + return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime; + } catch { + return false; + } + }, + + // 토큰 만료까지 남은 시간 (밀리초) + getTimeUntilExpiry: (token: string): number => { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + return payload.exp * 1000 - Date.now(); + } catch { + return 0; + } + }, }; +// 토큰 갱신 중복 방지 플래그 +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +// 토큰 갱신 함수 +const refreshToken = async (): Promise => { + // 이미 갱신 중이면 기존 Promise 반환 + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + const currentToken = TokenManager.getToken(); + if (!currentToken) { + return null; + } + + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + {}, + { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + } + ); + + if (response.data?.success && response.data?.data?.token) { + const newToken = response.data.data.token; + TokenManager.setToken(newToken); + console.log("[TokenManager] 토큰 갱신 성공"); + return newToken; + } + return null; + } catch (error) { + console.error("[TokenManager] 토큰 갱신 실패:", error); + return null; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +// 자동 토큰 갱신 타이머 +let tokenRefreshTimer: NodeJS.Timeout | null = null; + +// 자동 토큰 갱신 시작 +const startAutoRefresh = (): void => { + if (typeof window === "undefined") return; + + // 기존 타이머 정리 + if (tokenRefreshTimer) { + clearInterval(tokenRefreshTimer); + } + + // 10분마다 토큰 상태 확인 + tokenRefreshTimer = setInterval(async () => { + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작..."); + await refreshToken(); + } + }, 10 * 60 * 1000); // 10분 + + // 페이지 로드 시 즉시 확인 + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + refreshToken(); + } +}; + +// 사용자 활동 감지 및 토큰 갱신 +const setupActivityBasedRefresh = (): void => { + if (typeof window === "undefined") return; + + let lastActivity = Date.now(); + const activityThreshold = 5 * 60 * 1000; // 5분 + + const handleActivity = (): void => { + const now = Date.now(); + // 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인 + if (now - lastActivity > activityThreshold) { + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + refreshToken(); + } + } + lastActivity = now; + }; + + // 사용자 활동 이벤트 감지 + ["click", "keydown", "scroll", "mousemove"].forEach((event) => { + // 너무 잦은 호출 방지를 위해 throttle 적용 + let throttleTimer: NodeJS.Timeout | null = null; + window.addEventListener(event, () => { + if (!throttleTimer) { + throttleTimer = setTimeout(() => { + handleActivity(); + throttleTimer = null; + }, 1000); // 1초 throttle + } + }, { passive: true }); + }); +}; + +// 클라이언트 사이드에서 자동 갱신 시작 +if (typeof window !== "undefined") { + startAutoRefresh(); + setupActivityBasedRefresh(); +} + // Axios 인스턴스 생성 export const apiClient = axios.create({ baseURL: API_BASE_URL, @@ -138,9 +289,15 @@ apiClient.interceptors.request.use( // 응답 인터셉터 apiClient.interceptors.response.use( (response: AxiosResponse) => { + // 백엔드에서 보내주는 새로운 토큰 처리 + const newToken = response.headers["x-new-token"]; + if (newToken) { + TokenManager.setToken(newToken); + console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료"); + } return response; }, - (error: AxiosError) => { + async (error: AxiosError) => { const status = error.response?.status; const url = error.config?.url; @@ -153,7 +310,7 @@ apiClient.interceptors.response.use( } // 일반 409 에러는 간단한 로그만 출력 - console.warn("⚠️ 데이터 중복:", { + console.warn("데이터 중복:", { url: url, message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.", }); @@ -161,7 +318,7 @@ apiClient.interceptors.response.use( } // 다른 에러들은 기존처럼 상세 로그 출력 - console.error("❌ API 응답 오류:", { + console.error("API 응답 오류:", { status: status, statusText: error.response?.statusText, url: url, @@ -170,24 +327,40 @@ apiClient.interceptors.response.use( headers: error.config?.headers, }); - // 401 에러 시 상세 정보 출력 - if (status === 401) { - console.error("🚨 401 Unauthorized 오류 상세 정보:", { + // 401 에러 처리 + if (status === 401 && typeof window !== "undefined") { + const errorData = error.response?.data as { error?: { code?: string } }; + const errorCode = errorData?.error?.code; + + console.warn("[Auth] 401 오류 발생:", { url: url, - method: error.config?.method, - headers: error.config?.headers, - requestData: error.config?.data, - responseData: error.response?.data, + errorCode: errorCode, token: TokenManager.getToken() ? "존재" : "없음", }); - } - // 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트 - if (status === 401 && typeof window !== "undefined") { - localStorage.removeItem("authToken"); + // 토큰 만료 에러인 경우 갱신 시도 + const originalRequest = error.config as typeof error.config & { _retry?: boolean }; + if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) { + console.log("[Auth] 토큰 만료, 갱신 시도..."); + originalRequest._retry = true; + + try { + const newToken = await refreshToken(); + if (newToken && originalRequest) { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return apiClient.request(originalRequest); + } + } catch (refreshError) { + console.error("[Auth] 토큰 갱신 실패:", refreshError); + } + } + + // 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃 + TokenManager.removeToken(); // 로그인 페이지가 아닌 경우에만 리다이렉트 if (window.location.pathname !== "/login") { + console.log("[Auth] 로그인 페이지로 리다이렉트"); window.location.href = "/login"; } } From f106afdf82298393ebfc95ea248afb6a12531d68 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Dec 2025 11:44:07 +0900 Subject: [PATCH 08/24] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B0=99=EC=9D=B4?= =?UTF-8?q?=20=EC=A4=84=EC=96=B4=EB=93=A4=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 --- docs/노드플로우_개선사항.md | 582 ++++++++++++++++++ .../app/(main)/screens/[screenId]/page.tsx | 12 +- 2 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 docs/노드플로우_개선사항.md diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md new file mode 100644 index 00000000..3fe6cde2 --- /dev/null +++ b/docs/노드플로우_개선사항.md @@ -0,0 +1,582 @@ +# 노드 플로우 기능 개선 사항 + +> 작성일: 2024-12-08 +> 상태: 분석 완료, 개선 대기 + +## 현재 구현 상태 + +### 잘 구현된 기능 + +| 기능 | 상태 | 설명 | +|------|------|------| +| 위상 정렬 실행 | 완료 | DAG 기반 레벨별 실행 | +| 트랜잭션 관리 | 완료 | 전체 플로우 단일 트랜잭션, 실패 시 자동 롤백 | +| 병렬 실행 | 완료 | 같은 레벨 노드 `Promise.allSettled`로 병렬 처리 | +| CRUD 액션 | 완료 | INSERT, UPDATE, DELETE, UPSERT 지원 | +| 외부 DB 연동 | 완료 | PostgreSQL, MySQL, MSSQL, Oracle 지원 | +| REST API 연동 | 완료 | GET, POST, PUT, DELETE 지원 | +| 조건 분기 | 완료 | 다양한 연산자 지원 | +| 데이터 변환 | 부분 완료 | UPPERCASE, TRIM, EXPLODE 등 기본 변환 | +| 집계 함수 | 완료 | SUM, COUNT, AVG, MIN, MAX, FIRST, LAST | + +### 관련 파일 + +- **백엔드 실행 엔진**: `backend-node/src/services/nodeFlowExecutionService.ts` +- **백엔드 라우트**: `backend-node/src/routes/dataflow/node-flows.ts` +- **프론트엔드 API**: `frontend/lib/api/nodeFlows.ts` +- **프론트엔드 에디터**: `frontend/components/dataflow/node-editor/FlowEditor.tsx` +- **타입 정의**: `backend-node/src/types/flow.ts` + +--- + +## 개선 필요 사항 + +### 1. [우선순위 높음] 실행 이력 로깅 + +**현재 상태**: 플로우 실행 이력이 저장되지 않음 + +**문제점**: +- 언제, 누가, 어떤 플로우를 실행했는지 추적 불가 +- 실패 원인 분석 어려움 +- 감사(Audit) 요구사항 충족 불가 + +**개선 방안**: + +```sql +-- db/migrations/XXX_add_node_flow_execution_log.sql +CREATE TABLE node_flow_execution_log ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE, + execution_status VARCHAR(20) NOT NULL, -- 'success', 'failed', 'partial' + execution_time_ms INTEGER, + total_nodes INTEGER, + success_nodes INTEGER, + failed_nodes INTEGER, + skipped_nodes INTEGER, + executed_by VARCHAR(50), + company_code VARCHAR(20), + context_data JSONB, + result_summary JSONB, + error_message TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_flow_execution_log_flow_id ON node_flow_execution_log(flow_id); +CREATE INDEX idx_flow_execution_log_created_at ON node_flow_execution_log(created_at DESC); +CREATE INDEX idx_flow_execution_log_company_code ON node_flow_execution_log(company_code); +``` + +**필요 작업**: +- [ ] 마이그레이션 파일 생성 +- [ ] `nodeFlowExecutionService.ts`에 로그 저장 로직 추가 +- [ ] 실행 이력 조회 API 추가 (`GET /api/dataflow/node-flows/:flowId/executions`) +- [ ] 프론트엔드 실행 이력 UI 추가 + +--- + +### 2. [우선순위 높음] 드라이런(Dry Run) 모드 + +**현재 상태**: 실제 데이터를 변경하지 않고 테스트할 방법 없음 + +**문제점**: +- 프로덕션 데이터에 직접 영향 +- 플로우 디버깅 어려움 +- 신규 플로우 검증 불가 + +**개선 방안**: + +```typescript +// nodeFlowExecutionService.ts +static async executeFlow( + flowId: number, + contextData: Record, + options: { dryRun?: boolean } = {} +): Promise { + if (options.dryRun) { + // 트랜잭션 시작 후 항상 롤백 + return transaction(async (client) => { + const result = await this.executeFlowInternal(flowId, contextData, client); + // 롤백을 위해 의도적으로 에러 발생 + throw new DryRunComplete(result); + }).catch((e) => { + if (e instanceof DryRunComplete) { + return { ...e.result, dryRun: true }; + } + throw e; + }); + } + // 기존 로직... +} +``` + +```typescript +// node-flows.ts 라우트 수정 +router.post("/:flowId/execute", async (req, res) => { + const dryRun = req.query.dryRun === 'true'; + const result = await NodeFlowExecutionService.executeFlow( + parseInt(flowId, 10), + enrichedContextData, + { dryRun } + ); + // ... +}); +``` + +**필요 작업**: +- [ ] `DryRunComplete` 예외 클래스 생성 +- [ ] `executeFlow` 메서드에 `dryRun` 옵션 추가 +- [ ] 라우트에 쿼리 파라미터 처리 추가 +- [ ] 프론트엔드 "테스트 실행" 버튼 추가 + +--- + +### 3. [우선순위 높음] 재시도 메커니즘 + +**현재 상태**: 외부 API/DB 호출 실패 시 재시도 없음 + +**문제점**: +- 일시적 네트워크 오류로 전체 플로우 실패 +- 외부 서비스 불안정 시 신뢰성 저하 + +**개선 방안**: + +```typescript +// utils/retry.ts +export async function withRetry( + fn: () => Promise, + options: { + maxRetries?: number; + delay?: number; + backoffMultiplier?: number; + retryOn?: (error: any) => boolean; + } = {} +): Promise { + const { + maxRetries = 3, + delay = 1000, + backoffMultiplier = 2, + retryOn = () => true + } = options; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxRetries - 1 || !retryOn(error)) { + throw error; + } + const waitTime = delay * Math.pow(backoffMultiplier, attempt); + logger.warn(`재시도 ${attempt + 1}/${maxRetries}, ${waitTime}ms 후...`); + await new Promise(r => setTimeout(r, waitTime)); + } + } + throw new Error('재시도 횟수 초과'); +} +``` + +```typescript +// nodeFlowExecutionService.ts에서 사용 +const response = await withRetry( + () => axios({ method, url, headers, data, timeout }), + { + maxRetries: 3, + delay: 1000, + retryOn: (err) => err.code === 'ECONNRESET' || err.response?.status >= 500 + } +); +``` + +**필요 작업**: +- [ ] `withRetry` 유틸리티 함수 생성 +- [ ] REST API 호출 부분에 재시도 로직 적용 +- [ ] 외부 DB 연결 부분에 재시도 로직 적용 +- [ ] 노드별 재시도 설정 UI 추가 (선택사항) + +--- + +### 4. [우선순위 높음] 미완성 데이터 변환 함수 + +**현재 상태**: FORMAT, CALCULATE, JSON_EXTRACT, CUSTOM 변환이 미구현 + +**문제점**: +- 날짜/숫자 포맷팅 불가 +- 계산식 처리 불가 +- JSON 데이터 파싱 불가 + +**개선 방안**: + +```typescript +// nodeFlowExecutionService.ts - applyTransformation 메서드 수정 + +case "FORMAT": + return rows.map((row) => { + const value = row[sourceField]; + let formatted = value; + + if (transform.formatType === 'date') { + // dayjs 사용 + formatted = dayjs(value).format(transform.formatPattern || 'YYYY-MM-DD'); + } else if (transform.formatType === 'number') { + // 숫자 포맷팅 + const num = parseFloat(value); + if (transform.formatPattern === 'currency') { + formatted = num.toLocaleString('ko-KR', { style: 'currency', currency: 'KRW' }); + } else if (transform.formatPattern === 'percent') { + formatted = (num * 100).toFixed(transform.decimals || 0) + '%'; + } else { + formatted = num.toLocaleString('ko-KR', { maximumFractionDigits: transform.decimals || 2 }); + } + } + + return { ...row, [actualTargetField]: formatted }; + }); + +case "CALCULATE": + return rows.map((row) => { + // 간단한 수식 평가 (보안 주의!) + const expression = transform.expression; // 예: "price * quantity" + const result = evaluateExpression(expression, row); + return { ...row, [actualTargetField]: result }; + }); + +case "JSON_EXTRACT": + return rows.map((row) => { + const jsonValue = typeof row[sourceField] === 'string' + ? JSON.parse(row[sourceField]) + : row[sourceField]; + const extracted = jsonPath.query(jsonValue, transform.jsonPath); // JSONPath 라이브러리 사용 + return { ...row, [actualTargetField]: extracted[0] || null }; + }); +``` + +**필요 작업**: +- [ ] `dayjs` 라이브러리 추가 (날짜 포맷팅) +- [ ] `jsonpath` 라이브러리 추가 (JSON 추출) +- [ ] 안전한 수식 평가 함수 구현 (eval 대신) +- [ ] 각 변환 타입별 UI 설정 패널 추가 + +--- + +### 5. [우선순위 중간] 플로우 버전 관리 + +**현재 상태**: 플로우 수정 시 이전 버전 덮어씀 + +**문제점**: +- 실수로 수정한 플로우 복구 불가 +- 변경 이력 추적 불가 + +**개선 방안**: + +```sql +-- db/migrations/XXX_add_node_flow_versions.sql +CREATE TABLE node_flow_versions ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE, + version INTEGER NOT NULL, + flow_data JSONB NOT NULL, + change_description TEXT, + created_by VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(flow_id, version) +); + +CREATE INDEX idx_flow_versions_flow_id ON node_flow_versions(flow_id); +``` + +```typescript +// 플로우 수정 시 버전 저장 +async function updateNodeFlow(flowId, flowData, changeDescription, userId) { + // 현재 버전 조회 + const currentVersion = await queryOne( + 'SELECT COALESCE(MAX(version), 0) as max_version FROM node_flow_versions WHERE flow_id = $1', + [flowId] + ); + + // 새 버전 저장 + await query( + 'INSERT INTO node_flow_versions (flow_id, version, flow_data, change_description, created_by) VALUES ($1, $2, $3, $4, $5)', + [flowId, currentVersion.max_version + 1, flowData, changeDescription, userId] + ); + + // 기존 업데이트 로직... +} +``` + +**필요 작업**: +- [ ] 버전 테이블 마이그레이션 생성 +- [ ] 플로우 수정 시 버전 자동 저장 +- [ ] 버전 목록 조회 API (`GET /api/dataflow/node-flows/:flowId/versions`) +- [ ] 특정 버전으로 롤백 API (`POST /api/dataflow/node-flows/:flowId/rollback/:version`) +- [ ] 프론트엔드 버전 히스토리 UI + +--- + +### 6. [우선순위 중간] 복합 조건 지원 + +**현재 상태**: 조건 노드에서 단일 조건만 지원 + +**문제점**: +- 복잡한 비즈니스 로직 표현 불가 +- 여러 조건을 AND/OR로 조합 불가 + +**개선 방안**: + +```typescript +// 복합 조건 타입 정의 +interface ConditionGroup { + type: 'AND' | 'OR'; + conditions: (Condition | ConditionGroup)[]; +} + +interface Condition { + field: string; + operator: string; + value: any; +} + +// 조건 평가 함수 수정 +function evaluateConditionGroup(group: ConditionGroup, data: any): boolean { + const results = group.conditions.map(condition => { + if ('type' in condition) { + // 중첩된 그룹 + return evaluateConditionGroup(condition, data); + } else { + // 단일 조건 + return evaluateCondition(data[condition.field], condition.operator, condition.value); + } + }); + + return group.type === 'AND' + ? results.every(r => r) + : results.some(r => r); +} +``` + +**필요 작업**: +- [ ] 복합 조건 타입 정의 +- [ ] `evaluateConditionGroup` 함수 구현 +- [ ] 조건 노드 속성 패널 UI 수정 (AND/OR 그룹 빌더) + +--- + +### 7. [우선순위 중간] 비동기 실행 + +**현재 상태**: 동기 실행만 가능 (HTTP 요청 타임아웃 제한) + +**문제점**: +- 대용량 데이터 처리 시 타임아웃 +- 장시간 실행 플로우 처리 불가 + +**개선 방안**: + +```sql +-- 실행 큐 테이블 +CREATE TABLE node_flow_execution_queue ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id), + execution_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed + context_data JSONB, + callback_url TEXT, + result JSONB, + error_message TEXT, + queued_by VARCHAR(50), + company_code VARCHAR(20), + queued_at TIMESTAMP DEFAULT NOW(), + started_at TIMESTAMP, + completed_at TIMESTAMP +); +``` + +```typescript +// 비동기 실행 API +router.post("/:flowId/execute-async", async (req, res) => { + const { callbackUrl, contextData } = req.body; + + // 큐에 추가 + const execution = await queryOne( + `INSERT INTO node_flow_execution_queue (flow_id, context_data, callback_url, queued_by, company_code) + VALUES ($1, $2, $3, $4, $5) RETURNING execution_id`, + [flowId, contextData, callbackUrl, req.user?.userId, req.user?.companyCode] + ); + + // 백그라운드 워커가 처리 + return res.json({ + success: true, + executionId: execution.execution_id, + status: 'queued' + }); +}); + +// 상태 조회 API +router.get("/executions/:executionId", async (req, res) => { + const execution = await queryOne( + 'SELECT * FROM node_flow_execution_queue WHERE execution_id = $1', + [req.params.executionId] + ); + return res.json({ success: true, data: execution }); +}); +``` + +**필요 작업**: +- [ ] 실행 큐 테이블 마이그레이션 +- [ ] 비동기 실행 API 추가 +- [ ] 백그라운드 워커 프로세스 구현 (별도 프로세스 또는 Bull 큐) +- [ ] 웹훅 콜백 기능 구현 +- [ ] 프론트엔드 비동기 실행 상태 폴링 UI + +--- + +### 8. [우선순위 낮음] 플로우 스케줄링 + +**현재 상태**: 수동 실행만 가능 + +**문제점**: +- 정기적인 배치 작업 자동화 불가 +- 특정 시간 예약 실행 불가 + +**개선 방안**: + +```sql +-- 스케줄 테이블 +CREATE TABLE node_flow_schedules ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE, + schedule_name VARCHAR(100), + cron_expression VARCHAR(50) NOT NULL, -- '0 9 * * 1-5' (평일 9시) + context_data JSONB, + is_active BOOLEAN DEFAULT true, + last_run_at TIMESTAMP, + next_run_at TIMESTAMP, + created_by VARCHAR(50), + company_code VARCHAR(20), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +**필요 작업**: +- [ ] 스케줄 테이블 마이그레이션 +- [ ] 스케줄 CRUD API +- [ ] node-cron 또는 Bull 스케줄러 통합 +- [ ] 스케줄 관리 UI + +--- + +### 9. [우선순위 낮음] 플러그인 아키텍처 + +**현재 상태**: 새 노드 타입 추가 시 `nodeFlowExecutionService.ts` 직접 수정 필요 + +**문제점**: +- 코드 복잡도 증가 +- 확장성 제한 + +**개선 방안**: + +```typescript +// interfaces/NodeHandler.ts +export interface NodeHandler { + type: string; + execute(node: FlowNode, inputData: any, context: ExecutionContext, client?: any): Promise; + validate?(node: FlowNode): { valid: boolean; errors: string[] }; +} + +// handlers/InsertActionHandler.ts +export class InsertActionHandler implements NodeHandler { + type = 'insertAction'; + + async execute(node, inputData, context, client) { + // 기존 executeInsertAction 로직 + } +} + +// NodeHandlerRegistry.ts +class NodeHandlerRegistry { + private handlers = new Map(); + + register(handler: NodeHandler) { + this.handlers.set(handler.type, handler); + } + + get(type: string): NodeHandler | undefined { + return this.handlers.get(type); + } +} + +// 사용 +const registry = new NodeHandlerRegistry(); +registry.register(new InsertActionHandler()); +registry.register(new UpdateActionHandler()); +// ... + +// executeNodeByType에서 +const handler = registry.get(node.type); +if (handler) { + return handler.execute(node, inputData, context, client); +} +``` + +**필요 작업**: +- [ ] `NodeHandler` 인터페이스 정의 +- [ ] 기존 노드 타입별 핸들러 클래스 분리 +- [ ] `NodeHandlerRegistry` 구현 +- [ ] 커스텀 노드 핸들러 등록 메커니즘 + +--- + +### 10. [우선순위 낮음] 프론트엔드 연동 강화 + +**현재 상태**: 기본 에디터 구현됨 + +**개선 필요 항목**: +- [ ] 실행 결과 시각화 (노드별 성공/실패 표시) +- [ ] 실시간 실행 진행률 표시 +- [ ] 드라이런 모드 UI +- [ ] 실행 이력 조회 UI +- [ ] 버전 히스토리 UI +- [ ] 노드 검증 결과 표시 + +--- + +## 프론트엔드 컴포넌트 CRUD 로직 이전 계획 + +현재 프론트엔드 컴포넌트에서 직접 CRUD를 수행하는 코드들을 노드 플로우로 이전해야 합니다. + +### 이전 대상 컴포넌트 + +| 컴포넌트 | 파일 위치 | 현재 로직 | 이전 우선순위 | +|----------|----------|----------|--------------| +| SplitPanelLayoutComponent | `frontend/lib/registry/components/split-panel-layout/` | createRecord, updateRecord, deleteRecord | 높음 | +| RepeatScreenModalComponent | `frontend/lib/registry/components/repeat-screen-modal/` | 다중 테이블 INSERT/UPDATE/DELETE | 높음 | +| UniversalFormModalComponent | `frontend/lib/registry/components/universal-form-modal/` | 다중 행 저장 | 높음 | +| SelectedItemsDetailInputComponent | `frontend/lib/registry/components/selected-items-detail-input/` | upsertGroupedRecords | 높음 | +| ButtonPrimaryComponent | `frontend/lib/registry/components/button-primary/` | 상태 변경 POST | 중간 | +| SimpleRepeaterTableComponent | `frontend/lib/registry/components/simple-repeater-table/` | 데이터 저장 POST | 중간 | + +### 이전 방식 + +1. **플로우 생성**: 각 컴포넌트의 저장 로직을 노드 플로우로 구현 +2. **프론트엔드 수정**: 직접 API 호출 대신 `executeNodeFlow(flowId, contextData)` 호출 +3. **화면 설정에 플로우 연결**: 버튼 액션에 실행할 플로우 ID 설정 + +```typescript +// 현재 (프론트엔드에서 직접 호출) +const result = await dataApi.createRecord(tableName, data); + +// 개선 후 (플로우 실행) +const result = await executeNodeFlow(flowId, { + formData: data, + tableName: tableName, + action: 'create' +}); +``` + +--- + +## 참고 자료 + +- 노드 플로우 실행 엔진: `backend-node/src/services/nodeFlowExecutionService.ts` +- 플로우 타입 정의: `backend-node/src/types/flow.ts` +- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx` +- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts` + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 86362ec8..f556dae2 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -216,7 +216,8 @@ function ScreenViewPage() { initAutoFill(); }, [layout, user]); - // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화 + // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산 + // 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음 useEffect(() => { // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) if (isMobile) { @@ -262,13 +263,12 @@ function ScreenViewPage() { } }; - // 초기 측정 + // 초기 측정 (한 번만 실행) const timer = setTimeout(updateScale, 100); - window.addEventListener("resize", updateScale); + // resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록 return () => { clearTimeout(timer); - window.removeEventListener("resize", updateScale); }; }, [layout, isMobile]); @@ -309,7 +309,7 @@ function ScreenViewPage() {
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && ( @@ -334,7 +334,7 @@ function ScreenViewPage() { maxHeight: `${screenHeight}px`, flexShrink: 0, transform: `scale(${scale})`, - transformOrigin: "center center", + transformOrigin: "top left", overflow: "visible", }} > From 55d8474b62afdec1667e5f40a9f4f0b78dd9821e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Dec 2025 15:15:44 +0900 Subject: [PATCH 09/24] =?UTF-8?q?=EB=A0=89=20=EA=B5=AC=EC=A1=B0=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/노드플로우_개선사항.md | 1 + frontend/lib/registry/components/index.ts | 3 + .../components/rack-structure/README.md | 148 ++++ .../rack-structure/RackStructureComponent.tsx | 724 ++++++++++++++++++ .../RackStructureConfigPanel.tsx | 287 +++++++ .../rack-structure/RackStructureRenderer.tsx | 44 ++ .../components/rack-structure/config.ts | 27 + .../components/rack-structure/index.ts | 74 ++ .../components/rack-structure/types.ts | 91 +++ ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 12 files changed, 1402 insertions(+) create mode 100644 frontend/lib/registry/components/rack-structure/README.md create mode 100644 frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx create mode 100644 frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx create mode 100644 frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx create mode 100644 frontend/lib/registry/components/rack-structure/config.ts create mode 100644 frontend/lib/registry/components/rack-structure/index.ts create mode 100644 frontend/lib/registry/components/rack-structure/types.ts diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 3fe6cde2..85ae186b 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -580,3 +580,4 @@ const result = await executeNodeFlow(flowId, { - 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx` - 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts` + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 2a5d45e4..ff9d9240 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 // 🆕 범용 폼 모달 컴포넌트 import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원 +// 🆕 렉 구조 설정 컴포넌트 +import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/rack-structure/README.md b/frontend/lib/registry/components/rack-structure/README.md new file mode 100644 index 00000000..bf03d8dc --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/README.md @@ -0,0 +1,148 @@ +# 렉 구조 설정 컴포넌트 (Rack Structure Config) + +창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트입니다. + +## 핵심 개념 + +이 컴포넌트는 **상위 폼의 필드 값을 읽어서** 위치 코드를 생성합니다. + +### 작동 방식 + +1. 사용자가 화면관리에서 테이블 컬럼(창고코드, 층, 구역 등)을 드래그하여 폼에 배치 +2. 렉 구조 컴포넌트 설정에서 **필드 매핑** 설정 (어떤 폼 필드가 창고/층/구역인지) +3. 런타임에 사용자가 폼 필드에 값을 입력하면, 렉 구조 컴포넌트가 해당 값을 읽어서 사용 + +## 기능 + +### 1. 렉 라인 구조 설정 + +- 조건 추가/삭제 +- 각 조건: 열 범위(시작~종료) + 단 수 +- 자동 위치 수 계산 (예: 1열~3열 x 3단 = 9개) +- 템플릿 저장/불러오기 + +### 2. 등록 미리보기 + +- 통계 카드 (총 위치, 열 수, 최대 단) +- 미리보기 생성 버튼 +- 생성될 위치 목록 테이블 + +## 설정 방법 + +### 1. 화면관리에서 배치 + +1. 상위에 테이블 컬럼들을 배치 (창고코드, 층, 구역, 위치유형, 사용여부) +2. 컴포넌트 팔레트에서 "렉 구조 설정" 선택 +3. 캔버스에 드래그하여 배치 + +### 2. 필드 매핑 설정 + +설정 패널에서 상위 폼의 어떤 필드를 사용할지 매핑합니다: + +| 매핑 항목 | 설명 | +| -------------- | ------------------------------------- | +| 창고 코드 필드 | 위치 코드 생성에 사용할 창고 코드 | +| 층 필드 | 위치 코드 생성에 사용할 층 | +| 구역 필드 | 위치 코드 생성에 사용할 구역 | +| 위치 유형 필드 | 미리보기 테이블에 표시할 위치 유형 | +| 사용 여부 필드 | 미리보기 테이블에 표시할 사용 여부 | + +### 예시 + +상위 폼에 다음 필드가 배치되어 있다면: +- `창고코드(조인)` → 필드명: `warehouse_code` +- `층` → 필드명: `floor` +- `구역` → 필드명: `zone` + +설정 패널에서: +- 창고 코드 필드: `warehouse_code` 선택 +- 층 필드: `floor` 선택 +- 구역 필드: `zone` 선택 + +## 위치 코드 생성 규칙 + +기본 패턴: `{창고코드}-{층}{구역}-{열:2자리}-{단}` + +예시 (창고: WH001, 층: 1, 구역: A): + +- WH001-1A-01-1 (01열, 1단) +- WH001-1A-01-2 (01열, 2단) +- WH001-1A-02-1 (02열, 1단) + +## 설정 옵션 + +| 옵션 | 타입 | 기본값 | 설명 | +| -------------- | ------- | ------ | ---------------- | +| maxConditions | number | 10 | 최대 조건 수 | +| maxRows | number | 99 | 최대 열 수 | +| maxLevels | number | 20 | 최대 단 수 | +| showTemplates | boolean | true | 템플릿 기능 표시 | +| showPreview | boolean | true | 미리보기 표시 | +| showStatistics | boolean | true | 통계 카드 표시 | +| readonly | boolean | false | 읽기 전용 | + +## 출력 데이터 + +`onChange` 콜백으로 생성된 위치 데이터 배열을 반환합니다: + +```typescript +interface GeneratedLocation { + rowNum: number; // 열 번호 + levelNum: number; // 단 번호 + locationCode: string; // 위치 코드 + locationName: string; // 위치명 + locationType?: string; // 위치 유형 + status?: string; // 사용 여부 + warehouseCode?: string; // 창고 코드 (매핑된 값) + floor?: string; // 층 (매핑된 값) + zone?: string; // 구역 (매핑된 값) +} +``` + +## 연동 테이블 + +`warehouse_location` 테이블과 연동됩니다: + +| 컬럼 | 설명 | +| ------------- | --------- | +| warehouse_id | 창고 ID | +| floor | 층 | +| zone | 구역 | +| row_num | 열 번호 | +| level_num | 단 번호 | +| location_code | 위치 코드 | +| location_name | 위치명 | +| location_type | 위치 유형 | +| status | 사용 여부 | + +## 예시 시나리오 + +### 시나리오: A구역에 1~3열은 3단, 4~6열은 5단 렉 생성 + +1. **상위 폼에서 기본 정보 입력** + - 창고: 제1창고 (WH001) - 드래그해서 배치한 필드 + - 층: 1 - 드래그해서 배치한 필드 + - 구역: A - 드래그해서 배치한 필드 + - 위치 유형: 선반 - 드래그해서 배치한 필드 + - 사용 여부: 사용 - 드래그해서 배치한 필드 + +2. **렉 구조 컴포넌트에서 조건 추가** + - 조건 1: 1~3열, 3단 → 9개 + - 조건 2: 4~6열, 5단 → 15개 + +3. **미리보기 생성** + - 총 위치: 24개 + - 열 수: 6개 + - 최대 단: 5단 + +4. **저장** + - 24개의 위치 데이터가 warehouse_location 테이블에 저장됨 + +## 필수 필드 검증 + +미리보기 생성 시 다음 필드가 입력되어 있어야 합니다: +- 창고 코드 +- 층 +- 구역 + +필드가 비어있으면 경고 메시지가 표시됩니다. diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx new file mode 100644 index 00000000..f49e4462 --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -0,0 +1,724 @@ +"use client"; + +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { Plus, X, Save, FolderOpen, RefreshCw, Eye, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { cn } from "@/lib/utils"; +import { + RackStructureComponentProps, + RackLineCondition, + RackStructureTemplate, + GeneratedLocation, + RackStructureContext, +} from "./types"; + +// 고유 ID 생성 +const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + +// 조건 카드 컴포넌트 +interface ConditionCardProps { + condition: RackLineCondition; + index: number; + onUpdate: (id: string, updates: Partial) => void; + onRemove: (id: string) => void; + maxRows: number; + maxLevels: number; + readonly?: boolean; +} + +const ConditionCard: React.FC = ({ + condition, + index, + onUpdate, + onRemove, + maxRows, + maxLevels, + readonly, +}) => { + // 로컬 상태로 입력값 관리 + const [localValues, setLocalValues] = useState({ + startRow: condition.startRow.toString(), + endRow: condition.endRow.toString(), + levels: condition.levels.toString(), + }); + + // condition이 변경되면 로컬 상태 동기화 + useEffect(() => { + setLocalValues({ + startRow: condition.startRow.toString(), + endRow: condition.endRow.toString(), + levels: condition.levels.toString(), + }); + }, [condition.startRow, condition.endRow, condition.levels]); + + // 계산된 위치 수 + const locationCount = useMemo(() => { + const start = parseInt(localValues.startRow) || 0; + const end = parseInt(localValues.endRow) || 0; + const levels = parseInt(localValues.levels) || 0; + if (start > 0 && end >= start && levels > 0) { + return (end - start + 1) * levels; + } + return 0; + }, [localValues]); + + // 입력값 변경 핸들러 + const handleChange = (field: keyof typeof localValues, value: string) => { + setLocalValues((prev) => ({ ...prev, [field]: value })); + }; + + // blur 시 실제 업데이트 + const handleBlur = (field: keyof typeof localValues) => { + const numValue = parseInt(localValues[field]) || 0; + const clampedValue = Math.max(0, Math.min(numValue, field === "levels" ? maxLevels : maxRows)); + + setLocalValues((prev) => ({ ...prev, [field]: clampedValue.toString() })); + + const updateField = field === "startRow" ? "startRow" : field === "endRow" ? "endRow" : "levels"; + onUpdate(condition.id, { [updateField]: clampedValue }); + }; + + return ( +
+ {/* 헤더 */} +
+ 조건 {index + 1} + {!readonly && ( + + )} +
+ + {/* 내용 */} +
+ {/* 열 범위 */} +
+
+ +
+ handleChange("startRow", e.target.value)} + onBlur={() => handleBlur("startRow")} + disabled={readonly} + className="h-9 text-center" + /> + ~ + handleChange("endRow", e.target.value)} + onBlur={() => handleBlur("endRow")} + disabled={readonly} + className="h-9 text-center" + /> +
+
+
+ + handleChange("levels", e.target.value)} + onBlur={() => handleBlur("levels")} + disabled={readonly} + className="h-9 text-center" + /> +
+
+ + {/* 계산 결과 */} +
+ {locationCount > 0 ? ( + <> + {localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "} + {locationCount}개 + + ) : ( + 값을 입력하세요 + )} +
+
+
+ ); +}; + +// 메인 컴포넌트 +export const RackStructureComponent: React.FC = ({ + config, + context: propContext, + formData, + onChange, + onConditionsChange, + isPreview = false, +}) => { + // 조건 목록 + const [conditions, setConditions] = useState( + config.initialConditions || [] + ); + + // 템플릿 관련 상태 + const [templates, setTemplates] = useState([]); + const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false); + const [templateName, setTemplateName] = useState(""); + const [isSaveMode, setIsSaveMode] = useState(false); + + // 미리보기 데이터 + const [previewData, setPreviewData] = useState([]); + const [isPreviewGenerated, setIsPreviewGenerated] = useState(false); + + // 설정값 + const maxConditions = config.maxConditions || 10; + const maxRows = config.maxRows || 99; + const maxLevels = config.maxLevels || 20; + const readonly = config.readonly || isPreview; + const fieldMapping = config.fieldMapping || {}; + + // 필드 매핑을 통해 formData에서 컨텍스트 추출 + const context: RackStructureContext = useMemo(() => { + // propContext가 있으면 우선 사용 + if (propContext) return propContext; + + // formData와 fieldMapping을 사용하여 컨텍스트 생성 + if (!formData) return {}; + + return { + warehouseCode: fieldMapping.warehouseCodeField + ? formData[fieldMapping.warehouseCodeField] + : undefined, + warehouseName: fieldMapping.warehouseNameField + ? formData[fieldMapping.warehouseNameField] + : undefined, + floor: fieldMapping.floorField + ? formData[fieldMapping.floorField]?.toString() + : undefined, + zone: fieldMapping.zoneField + ? formData[fieldMapping.zoneField] + : undefined, + locationType: fieldMapping.locationTypeField + ? formData[fieldMapping.locationTypeField] + : undefined, + status: fieldMapping.statusField + ? formData[fieldMapping.statusField] + : undefined, + }; + }, [propContext, formData, fieldMapping]); + + // 필수 필드 검증 + const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.floor) missing.push("층"); + if (!context.zone) missing.push("구역"); + return missing; + }, [context]); + + // 조건 변경 시 콜백 호출 + useEffect(() => { + onConditionsChange?.(conditions); + setIsPreviewGenerated(false); // 조건 변경 시 미리보기 초기화 + }, [conditions, onConditionsChange]); + + // 조건 추가 + const addCondition = useCallback(() => { + if (conditions.length >= maxConditions) return; + + // 마지막 조건의 다음 열부터 시작 + const lastCondition = conditions[conditions.length - 1]; + const startRow = lastCondition ? lastCondition.endRow + 1 : 1; + + const newCondition: RackLineCondition = { + id: generateId(), + startRow, + endRow: startRow + 2, + levels: 3, + }; + + setConditions((prev) => [...prev, newCondition]); + }, [conditions, maxConditions]); + + // 조건 업데이트 + const updateCondition = useCallback((id: string, updates: Partial) => { + setConditions((prev) => + prev.map((cond) => (cond.id === id ? { ...cond, ...updates } : cond)) + ); + }, []); + + // 조건 삭제 + const removeCondition = useCallback((id: string) => { + setConditions((prev) => prev.filter((cond) => cond.id !== id)); + }, []); + + // 통계 계산 + const statistics = useMemo(() => { + let totalLocations = 0; + let totalRows = 0; + let maxLevel = 0; + const rowSet = new Set(); + + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + const rowCount = cond.endRow - cond.startRow + 1; + totalLocations += rowCount * cond.levels; + for (let r = cond.startRow; r <= cond.endRow; r++) { + rowSet.add(r); + } + maxLevel = Math.max(maxLevel, cond.levels); + } + }); + + totalRows = rowSet.size; + return { totalLocations, totalRows, maxLevel }; + }, [conditions]); + + // 위치 코드 생성 + const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor || "1"; + const zone = context?.zone || "A"; + + // 코드 생성 (예: WH001-1A-01-1) + const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + + // 이름 생성 (예: A구역-01열-1단) + const name = `${zone}구역-${row.toString().padStart(2, "0")}열-${level}단`; + + return { code, name }; + }, + [context] + ); + + // 미리보기 생성 + const generatePreview = useCallback(() => { + // 필수 필드 검증 + if (missingFields.length > 0) { + alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); + return; + } + + const locations: GeneratedLocation[] = []; + + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + for (let row = cond.startRow; row <= cond.endRow; row++) { + for (let level = 1; level <= cond.levels; level++) { + const { code, name } = generateLocationCode(row, level); + locations.push({ + rowNum: row, + levelNum: level, + locationCode: code, + locationName: name, + locationType: context?.locationType || "선반", + status: context?.status || "사용", + // 추가 필드 + warehouseCode: context?.warehouseCode, + floor: context?.floor, + zone: context?.zone, + }); + } + } + } + }); + + // 정렬: 열 -> 단 순서 + locations.sort((a, b) => { + if (a.rowNum !== b.rowNum) return a.rowNum - b.rowNum; + return a.levelNum - b.levelNum; + }); + + setPreviewData(locations); + setIsPreviewGenerated(true); + onChange?.(locations); + }, [conditions, context, generateLocationCode, onChange, missingFields]); + + // 템플릿 저장 + const saveTemplate = useCallback(() => { + if (!templateName.trim()) return; + + const newTemplate: RackStructureTemplate = { + id: generateId(), + name: templateName.trim(), + conditions: [...conditions], + createdAt: new Date().toISOString(), + }; + + setTemplates((prev) => [...prev, newTemplate]); + setTemplateName(""); + setIsTemplateDialogOpen(false); + }, [templateName, conditions]); + + // 템플릿 불러오기 + const loadTemplate = useCallback((template: RackStructureTemplate) => { + setConditions(template.conditions.map((c) => ({ ...c, id: generateId() }))); + setIsTemplateDialogOpen(false); + }, []); + + // 템플릿 삭제 + const deleteTemplate = useCallback((templateId: string) => { + setTemplates((prev) => prev.filter((t) => t.id !== templateId)); + }, []); + + return ( +
+ {/* 렉 라인 구조 설정 섹션 */} + + + +
+ 렉 라인 구조 설정 + + {!readonly && ( +
+ {config.showTemplates && ( + <> + + + )} + +
+ )} + + + {/* 필수 필드 경고 */} + {missingFields.length > 0 && ( + + + + 다음 필드를 먼저 입력해주세요: {missingFields.join(", ")} +
+ + (설정 패널에서 필드 매핑을 확인하세요) + +
+
+ )} + + {/* 현재 매핑된 값 표시 */} + {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && ( +
+ {(context.warehouseCode || context.warehouseName) && ( + + 창고: {context.warehouseName || context.warehouseCode} + {context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`} + + )} + {context.floor && ( + + 층: {context.floor} + + )} + {context.zone && ( + + 구역: {context.zone} + + )} + {context.locationType && ( + + 유형: {context.locationType} + + )} + {context.status && ( + + 상태: {context.status} + + )} +
+ )} + + {/* 안내 메시지 */} +
+
    +
  1. + + 1 + + 조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요 +
  2. +
  3. + + 2 + + 각 조건마다 열 범위와 단 수를 입력하세요 +
  4. +
  5. + + 3 + + 예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단) +
  6. +
+
+ + {/* 조건 목록 또는 빈 상태 */} + {conditions.length === 0 ? ( +
+
📦
+

조건을 추가하여 렉 구조를 설정하세요

+ {!readonly && ( + + )} +
+ ) : ( +
+ {conditions.map((condition, index) => ( +
+ +
+ ))} +
+ )} +
+ + + {/* 등록 미리보기 섹션 */} + {config.showPreview && conditions.length > 0 && ( + + + + + 등록 미리보기 + + + + + {/* 통계 카드 */} + {config.showStatistics && ( +
+
+
총 위치
+
{statistics.totalLocations}개
+
+
+
열 수
+
{statistics.totalRows}개
+
+
+
최대 단
+
{statistics.maxLevel}단
+
+
+ )} + + {/* 미리보기 테이블 */} + {isPreviewGenerated && previewData.length > 0 ? ( +
+ +
+ + + No + 위치코드 + 위치명 + + 구역 + + + 유형 + 비고 + + + + {previewData.map((loc, idx) => ( + + {idx + 1} + {loc.locationCode} + {loc.locationName} + {context?.floor || "1"} + {context?.zone || "A"} + + {loc.rowNum.toString().padStart(2, "0")} + + {loc.levelNum} + {loc.locationType} + - + + ))} + +
+ +
+ ) : ( +
+ +

미리보기 생성 버튼을 클릭하여 결과를 확인하세요

+
+ )} + + + )} + + {/* 템플릿 다이얼로그 */} + + + + + {isSaveMode ? "템플릿 저장" : "템플릿 관리"} + + + + {isSaveMode ? ( +
+
+ + setTemplateName(e.target.value)} + placeholder="템플릿 이름을 입력하세요" + /> +
+ + + + +
+ ) : ( +
+ {/* 저장 버튼 */} + {conditions.length > 0 && ( + + )} + + {/* 템플릿 목록 */} + {templates.length > 0 ? ( +
+
저장된 템플릿
+ + {templates.map((template) => ( +
+
+
{template.name}
+
+ {template.conditions.length}개 조건 +
+
+
+ + +
+
+ ))} +
+
+ ) : ( +
+ 저장된 템플릿이 없습니다 +
+ )} +
+ )} +
+
+
+ ); +}; + +// Wrapper 컴포넌트 (레지스트리용) +export const RackStructureWrapper: React.FC = (props) => { + return ( +
+ +
+ ); +}; + + diff --git a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx new file mode 100644 index 00000000..8f0c8177 --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx @@ -0,0 +1,287 @@ +"use client"; + +import React, { useState, useEffect } 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 { RackStructureComponentConfig, FieldMapping } from "./types"; + +interface RackStructureConfigPanelProps { + config: RackStructureComponentConfig; + onChange: (config: RackStructureComponentConfig) => void; + // 화면관리에서 전달하는 테이블 컬럼 정보 + tables?: Array<{ + tableName: string; + tableLabel?: string; + columns: Array<{ + columnName: string; + columnLabel?: string; + dataType?: string; + }>; + }>; +} + +export const RackStructureConfigPanel: React.FC = ({ + config, + onChange, + tables = [], +}) => { + // 사용 가능한 컬럼 목록 추출 + const [availableColumns, setAvailableColumns] = useState< + Array<{ value: string; label: string }> + >([]); + + useEffect(() => { + // 모든 테이블의 컬럼을 플랫하게 추출 + const columns: Array<{ value: string; label: string }> = []; + tables.forEach((table) => { + table.columns.forEach((col) => { + columns.push({ + value: col.columnName, + label: col.columnLabel || col.columnName, + }); + }); + }); + setAvailableColumns(columns); + }, [tables]); + + const handleChange = (key: keyof RackStructureComponentConfig, value: any) => { + onChange({ ...config, [key]: value }); + }; + + const handleFieldMappingChange = (field: keyof FieldMapping, value: string) => { + const currentMapping = config.fieldMapping || {}; + onChange({ + ...config, + fieldMapping: { + ...currentMapping, + [field]: value === "__none__" ? undefined : value, + }, + }); + }; + + const fieldMapping = config.fieldMapping || {}; + + return ( +
+ {/* 필드 매핑 섹션 */} +
+
필드 매핑
+

+ 상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요 +

+ + {/* 창고 코드 필드 */} +
+ + +
+ + {/* 창고명 필드 */} +
+ + +
+ + {/* 층 필드 */} +
+ + +
+ + {/* 구역 필드 */} +
+ + +
+ + {/* 위치 유형 필드 */} +
+ + +
+ + {/* 사용 여부 필드 */} +
+ + +
+
+ + {/* 제한 설정 */} +
+
제한 설정
+ +
+ + handleChange("maxConditions", parseInt(e.target.value) || 10)} + className="h-8" + /> +
+ +
+ + handleChange("maxRows", parseInt(e.target.value) || 99)} + className="h-8" + /> +
+ +
+ + handleChange("maxLevels", parseInt(e.target.value) || 20)} + className="h-8" + /> +
+
+ + {/* UI 설정 */} +
+
UI 설정
+ +
+ + handleChange("showTemplates", checked)} + /> +
+ +
+ + handleChange("showPreview", checked)} + /> +
+ +
+ + handleChange("showStatistics", checked)} + /> +
+ +
+ + handleChange("readonly", checked)} + /> +
+
+
+ ); +}; diff --git a/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx b/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx new file mode 100644 index 00000000..ab832f51 --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { RackStructureDefinition } from "./index"; +import { RackStructureComponent } from "./RackStructureComponent"; +import { GeneratedLocation } from "./types"; + +/** + * 렉 구조 설정 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class RackStructureRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = RackStructureDefinition; + + render(): React.ReactElement { + const { formData, isPreview, config } = this.props as any; + + return ( + + ); + } + + /** + * 생성된 위치 데이터 변경 핸들러 + */ + protected handleLocationsChange = (locations: GeneratedLocation[]) => { + // 생성된 위치 데이터를 formData에 저장 + this.updateComponent({ generatedLocations: locations }); + }; +} + +// 자동 등록 실행 +RackStructureRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + RackStructureRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/rack-structure/config.ts b/frontend/lib/registry/components/rack-structure/config.ts new file mode 100644 index 00000000..09d9d04b --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/config.ts @@ -0,0 +1,27 @@ +/** + * 렉 구조 컴포넌트 기본 설정 + */ + +import { RackStructureComponentConfig } from "./types"; + +export const defaultConfig: RackStructureComponentConfig = { + // 기본 제한 + maxConditions: 10, + maxRows: 99, + maxLevels: 20, + + // 기본 코드 패턴 + codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}", + namePattern: "{zone}구역-{row:02d}열-{level}단", + + // UI 설정 + showTemplates: true, + showPreview: true, + showStatistics: true, + readonly: false, + + // 초기 조건 없음 + initialConditions: [], +}; + + diff --git a/frontend/lib/registry/components/rack-structure/index.ts b/frontend/lib/registry/components/rack-structure/index.ts new file mode 100644 index 00000000..a84cc4c6 --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/index.ts @@ -0,0 +1,74 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { RackStructureWrapper } from "./RackStructureComponent"; +import { RackStructureConfigPanel } from "./RackStructureConfigPanel"; +import { defaultConfig } from "./config"; + +/** + * 렉 구조 컴포넌트 정의 + * 창고 렉 위치를 일괄 생성하기 위한 구조 설정 컴포넌트 + */ +export const RackStructureDefinition = createComponentDefinition({ + id: "rack-structure", + name: "렉 구조 설정", + nameEng: "Rack Structure Config", + description: "창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트", + category: ComponentCategory.INPUT, + webType: "component", + component: RackStructureWrapper, + defaultConfig: defaultConfig, + defaultSize: { + width: 1200, + height: 800, + gridColumnSpan: "12", + }, + configPanel: RackStructureConfigPanel, + icon: "LayoutGrid", + tags: ["창고", "렉", "위치", "구조", "일괄생성", "WMS"], + version: "1.0.0", + author: "개발팀", + documentation: ` +창고 렉 위치를 일괄 생성하기 위한 구조 설정 컴포넌트입니다. + +## 주요 기능 +- 조건별 열 범위 및 단 수 설정 +- 자동 위치 코드/이름 생성 +- 미리보기 및 통계 표시 +- 템플릿 저장/불러오기 + +## 사용 방법 +1. 상위 폼에서 창고, 층, 구역 정보 선택 +2. 조건 추가 버튼으로 렉 라인 조건 생성 +3. 각 조건의 열 범위와 단 수 입력 +4. 미리보기 생성으로 결과 확인 +5. 저장 시 생성된 위치 데이터가 함께 저장됨 + +## 컨텍스트 데이터 +formData에서 다음 필드를 자동으로 읽어옵니다: +- warehouse_id / warehouseId: 창고 ID +- warehouse_code / warehouseCode: 창고 코드 +- floor: 층 +- zone: 구역 +- location_type / locationType: 위치 유형 +- status: 사용 여부 + `, +}); + +// 타입 내보내기 +export type { + RackStructureComponentConfig, + RackStructureContext, + RackLineCondition, + RackStructureTemplate, + GeneratedLocation, +} from "./types"; + +// 컴포넌트 내보내기 +export { RackStructureComponent, RackStructureWrapper } from "./RackStructureComponent"; +export { RackStructureRenderer } from "./RackStructureRenderer"; +export { RackStructureConfigPanel } from "./RackStructureConfigPanel"; + + diff --git a/frontend/lib/registry/components/rack-structure/types.ts b/frontend/lib/registry/components/rack-structure/types.ts new file mode 100644 index 00000000..485a2208 --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/types.ts @@ -0,0 +1,91 @@ +/** + * 렉 구조 컴포넌트 타입 정의 + */ + +// 렉 라인 조건 (열 범위 + 단 수) +export interface RackLineCondition { + id: string; + startRow: number; // 시작 열 + endRow: number; // 종료 열 + levels: number; // 단 수 +} + +// 렉 구조 템플릿 +export interface RackStructureTemplate { + id: string; + name: string; + conditions: RackLineCondition[]; + createdAt?: string; +} + +// 생성될 위치 데이터 +export interface GeneratedLocation { + rowNum: number; // 열 번호 + levelNum: number; // 단 번호 + locationCode: string; // 위치 코드 (예: WH001-1A-01-1) + locationName: string; // 위치명 (예: A구역-01열-1단) + locationType?: string; // 위치 유형 + status?: string; // 사용 여부 + // 추가 필드 (상위 폼에서 매핑된 값) + warehouseCode?: string; + floor?: string; + zone?: string; +} + +// 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지) +export interface FieldMapping { + warehouseCodeField?: string; // 창고 코드로 사용할 폼 필드명 + warehouseNameField?: string; // 창고명으로 사용할 폼 필드명 + floorField?: string; // 층으로 사용할 폼 필드명 + zoneField?: string; // 구역으로 사용할 폼 필드명 + locationTypeField?: string; // 위치 유형으로 사용할 폼 필드명 + statusField?: string; // 사용 여부로 사용할 폼 필드명 +} + +// 컴포넌트 설정 +export interface RackStructureComponentConfig { + // 기본 설정 + maxConditions?: number; // 최대 조건 수 (기본: 10) + maxRows?: number; // 최대 열 수 (기본: 99) + maxLevels?: number; // 최대 단 수 (기본: 20) + + // 필드 매핑 (상위 폼의 필드와 연결) + fieldMapping?: FieldMapping; + + // 위치 코드 생성 규칙 + codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}") + namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단") + + // UI 설정 + showTemplates?: boolean; // 템플릿 기능 표시 + showPreview?: boolean; // 미리보기 표시 + showStatistics?: boolean; // 통계 카드 표시 + readonly?: boolean; // 읽기 전용 + + // 초기값 + initialConditions?: RackLineCondition[]; +} + +// 상위 폼에서 전달받는 컨텍스트 데이터 +export interface RackStructureContext { + warehouseId?: string; // 창고 ID + warehouseCode?: string; // 창고 코드 (예: WH001) + warehouseName?: string; // 창고명 (예: 제1창고) + floor?: string; // 층 (예: 1) + zone?: string; // 구역 (예: A) + locationType?: string; // 위치 유형 (예: 선반) + status?: string; // 사용 여부 (예: 사용) +} + +// 컴포넌트 Props +export interface RackStructureComponentProps { + config: RackStructureComponentConfig; + context?: RackStructureContext; + formData?: Record; // 상위 폼 데이터 (필드 매핑에 사용) + onChange?: (locations: GeneratedLocation[]) => void; + onConditionsChange?: (conditions: RackLineCondition[]) => void; + isPreview?: boolean; + tableName?: string; +} + + diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 313a7567..29da36f1 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1682,3 +1682,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 373b6ec7..2f382cb3 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -529,3 +529,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 5d315706..8e4cdbd2 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -516,3 +516,4 @@ function ScreenViewPage() { + From 179ff5b68b9959fae8864c3519ec09c15f84c617 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 5 Dec 2025 16:44:58 +0900 Subject: [PATCH 10/24] =?UTF-8?q?=EC=9D=BC=EB=8B=A8=20=EC=9B=94=EC=9A=94?= =?UTF-8?q?=EC=9D=BC=EC=97=90=20=EC=83=81=EC=9D=98=ED=95=B4=EC=95=BC?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20=EC=97=AC=EA=B8=B0=EC=97=90=EB=8B=A4?= =?UTF-8?q?=EB=A7=8C=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 3204 ++++++++++++++++- 1 file changed, 3059 insertions(+), 145 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 64e6e540..85155eb2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -22,7 +22,18 @@ import { X, Layers, ChevronDown, + Filter, + Check, + Download, + FileSpreadsheet, + Copy, + ClipboardCopy, + Edit, + CheckSquare, + Trash2, } from "lucide-react"; +import * as XLSX from "xlsx"; +import { FileText, ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -62,6 +73,7 @@ interface GroupedData { groupValues: Record; items: any[]; count: number; + summary?: Record; // 🆕 그룹별 소계 } // ======================================== @@ -125,6 +137,35 @@ const debouncedApiCall = (key: string, fn: (...args: T) => P }; }; +// ======================================== +// Filter Builder 인터페이스 +// ======================================== + +interface FilterCondition { + id: string; + column: string; + operator: + | "equals" + | "notEquals" + | "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "greaterThan" + | "lessThan" + | "greaterOrEqual" + | "lessOrEqual" + | "isEmpty" + | "isNotEmpty"; + value: string; +} + +interface FilterGroup { + id: string; + logic: "AND" | "OR"; + conditions: FilterCondition[]; +} + // ======================================== // Props 인터페이스 // ======================================== @@ -328,28 +369,153 @@ export const TableListComponent: React.FC = ({ } }, [columnVisibility, tableConfig.selectedTable, currentUserId]); + // 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용) + const [columnOrder, setColumnOrder] = useState([]); + + // 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해) + const visibleColumns = useMemo(() => { + let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); + + // columnVisibility가 있으면 가시성 적용 + if (columnVisibility.length > 0) { + cols = cols.filter((col) => { + const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); + return visibilityConfig ? visibilityConfig.visible : true; + }); + } + + // 체크박스 컬럼 (나중에 위치 결정) + // 기본값: enabled가 undefined면 true로 처리 + let checkboxCol: ColumnConfig | null = null; + if (tableConfig.checkbox?.enabled ?? true) { + checkboxCol = { + columnName: "__checkbox__", + displayName: "", + webType: "checkbox", + visible: true, + sortable: false, + filterable: false, + width: 40, + }; + } + + // columnOrder가 있으면 해당 순서로 정렬 + if (columnOrder.length > 0) { + const orderMap = new Map(columnOrder.map((name, idx) => [name, idx])); + cols = [...cols].sort((a, b) => { + const aIdx = orderMap.get(a.columnName) ?? 9999; + const bIdx = orderMap.get(b.columnName) ?? 9999; + return aIdx - bIdx; + }); + } + + // 체크박스 위치 결정 + if (checkboxCol) { + const checkboxPosition = tableConfig.checkbox?.position || "left"; + if (checkboxPosition === "left") { + return [checkboxCol, ...cols]; + } else { + return [...cols, checkboxCol]; + } + } + + return cols; + }, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]); + const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) + const [headerFilters, setHeaderFilters] = useState>>({}); + const [openFilterColumn, setOpenFilterColumn] = useState(null); + + // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 + const [filterGroups, setFilterGroups] = useState([]); + + // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { - // 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링 + let result = data; + + // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; - const filtered = data.filter((row) => { + result = result.filter((row) => { const rowId = String(row.id || row.po_item_id || row.item_id || ""); return !addedIds.has(rowId); }); - console.log("🔍 [TableList] 우측 추가 항목 필터링:", { - originalCount: data.length, - filteredCount: filtered.length, - addedIdsCount: addedIds.size, - }); - return filtered; } - return data; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds]); + + // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) + if (Object.keys(headerFilters).length > 0) { + result = result.filter((row) => { + return Object.entries(headerFilters).every(([columnName, values]) => { + if (values.size === 0) return true; + + // 여러 가능한 컬럼명 시도 + const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; + const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; + + return values.has(cellStr); + }); + }); + } + + // 3. 🆕 Filter Builder 적용 + if (filterGroups.length > 0) { + result = result.filter((row) => { + return filterGroups.every((group) => { + const validConditions = group.conditions.filter( + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + ); + if (validConditions.length === 0) return true; + + const evaluateCondition = (value: any, condition: typeof group.conditions[0]): boolean => { + const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; + const condValue = condition.value.toLowerCase(); + + switch (condition.operator) { + case "equals": + return strValue === condValue; + case "notEquals": + return strValue !== condValue; + case "contains": + return strValue.includes(condValue); + case "notContains": + return !strValue.includes(condValue); + case "startsWith": + return strValue.startsWith(condValue); + case "endsWith": + return strValue.endsWith(condValue); + case "greaterThan": + return parseFloat(strValue) > parseFloat(condValue); + case "lessThan": + return parseFloat(strValue) < parseFloat(condValue); + case "greaterOrEqual": + return parseFloat(strValue) >= parseFloat(condValue); + case "lessOrEqual": + return parseFloat(strValue) <= parseFloat(condValue); + case "isEmpty": + return strValue === "" || value === null || value === undefined; + case "isNotEmpty": + return strValue !== "" && value !== null && value !== undefined; + default: + return true; + } + }; + + if (group.logic === "AND") { + return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); + } else { + return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); + } + }); + }); + } + + return result; + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); + const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); @@ -377,7 +543,7 @@ export const TableListComponent: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); const [refreshTrigger, setRefreshTrigger] = useState(0); - const [columnOrder, setColumnOrder] = useState([]); + // columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요) const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); const hasInitializedWidths = useRef(false); @@ -387,16 +553,117 @@ export const TableListComponent: React.FC = ({ const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // 🆕 키보드 네비게이션 관련 상태 + const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + const tableContainerRef = useRef(null); + + // 🆕 인라인 셀 편집 관련 상태 + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; + } | null>(null); + const [editingValue, setEditingValue] = useState(""); + const editInputRef = useRef(null); + + // 🆕 배치 편집 관련 상태 + const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 + const [pendingChanges, setPendingChanges] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 + + // 🆕 유효성 검사 관련 상태 + const [validationErrors, setValidationErrors] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + + // 🆕 유효성 검사 규칙 타입 + type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null) + }; + + // 🆕 Cascading Lookups 관련 상태 + const [cascadingOptions, setCascadingOptions] = useState>({}); + const [loadingCascading, setLoadingCascading] = useState>({}); + + // 🆕 Multi-Level Headers (Column Bands) 타입 + type ColumnBand = { + caption: string; + columns: string[]; // 포함될 컬럼명 배열 + }; + // 그룹 설정 관련 상태 const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // 🆕 Master-Detail 관련 상태 + const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 + const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 + + // 🆕 Drag & Drop 재정렬 관련 상태 + const [draggedRowIndex, setDraggedRowIndex] = useState(null); + const [dropTargetIndex, setDropTargetIndex] = useState(null); + const [isDragEnabled, setIsDragEnabled] = useState((tableConfig as any).enableRowDrag ?? false); + + // 🆕 Virtual Scrolling 관련 상태 + const [isVirtualScrollEnabled] = useState((tableConfig as any).virtualScroll ?? false); + const [scrollTop, setScrollTop] = useState(0); + const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀) + const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수 + const scrollContainerRef = useRef(null); + + // 🆕 Column Reordering 관련 상태 + const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); + const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); + const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); + + // 🆕 State Persistence: 통합 상태 키 + const tableStateKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableState_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + + // 🆕 Real-Time Updates 관련 상태 + const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); + const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected"); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + // 🆕 Context Menu 관련 상태 + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + rowIndex: number; + colIndex: number; + row: any; + } | null>(null); + // 사용자 옵션 모달 관련 상태 const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [frozenColumns, setFrozenColumns] = useState([]); + // 🆕 Search Panel (통합 검색) 관련 상태 + const [globalSearchTerm, setGlobalSearchTerm] = useState(""); + const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); + const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" 형식 + + // 🆕 Filter Builder (고급 필터) 관련 상태 추가 + const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); + const [activeFilterCount, setActiveFilterCount] = useState(0); + // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; @@ -800,9 +1067,10 @@ export const TableListComponent: React.FC = ({ // 전역 저장소에 데이터 저장 if (tableConfig.selectedTable) { - // 컬럼 라벨 매핑 생성 + // 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); @@ -815,7 +1083,7 @@ export const TableListComponent: React.FC = ({ { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, @@ -1418,21 +1686,23 @@ export const TableListComponent: React.FC = ({ setError(null); // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) + // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); tableDisplayStore.setTableData( tableConfig.selectedTable, response.data || [], - visibleColumns.map((col) => col.columnName), + cols.map((col) => col.columnName), sortBy, sortOrder, { filterConditions: filters, searchTerm: search, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: page, pageSize: pageSize, @@ -1552,9 +1822,11 @@ export const TableListComponent: React.FC = ({ }); // 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬 + // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const reorderedData = sortedData.map((row: any) => { const reordered: any = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { if (col.columnName in row) { reordered[col.columnName] = row[col.columnName]; } @@ -1590,12 +1862,12 @@ export const TableListComponent: React.FC = ({ // 전역 저장소에 정렬된 데이터 저장 if (tableConfig.selectedTable) { const cleanColumnOrder = ( - columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName) + columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName) ).filter((col) => col !== "__checkbox__"); // 컬럼 라벨 정보도 함께 저장 const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); @@ -1608,7 +1880,7 @@ export const TableListComponent: React.FC = ({ { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, @@ -1783,6 +2055,1495 @@ export const TableListComponent: React.FC = ({ console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; + // 🆕 셀 클릭 핸들러 (포커스 설정) + const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { + e.stopPropagation(); + setFocusedCell({ rowIndex, colIndex }); + // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) + tableContainerRef.current?.focus(); + }; + + // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 + const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { + // 체크박스 컬럼은 편집 불가 + if (columnName === "__checkbox__") return; + + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); + setEditingValue(value !== null && value !== undefined ? String(value) : ""); + setFocusedCell({ rowIndex, colIndex }); + }, []); + + // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) + const startEditingRef = useRef<() => void>(() => {}); + + // 🆕 각 컬럼의 고유값 목록 계산 + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + + if (data.length === 0) return result; + + (tableConfig.columns || []).forEach((column: { columnName: string }) => { + if (column.columnName === "__checkbox__") return; + + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const values = new Set(); + + data.forEach((row) => { + const val = row[mappedColumnName]; + if (val !== null && val !== undefined && val !== "") { + values.add(String(val)); + } + }); + + result[column.columnName] = Array.from(values).sort(); + }); + + return result; + }, [data, tableConfig.columns, joinColumnMapping]); + + // 🆕 헤더 필터 토글 + const toggleHeaderFilter = useCallback((columnName: string, value: string) => { + setHeaderFilters((prev) => { + const current = prev[columnName] || new Set(); + const newSet = new Set(current); + + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + + return { ...prev, [columnName]: newSet }; + }); + }, []); + + // 🆕 헤더 필터 초기화 + const clearHeaderFilter = useCallback((columnName: string) => { + setHeaderFilters((prev) => { + const newFilters = { ...prev }; + delete newFilters[columnName]; + return newFilters; + }); + setOpenFilterColumn(null); + }, []); + + // 🆕 모든 헤더 필터 초기화 + const clearAllHeaderFilters = useCallback(() => { + setHeaderFilters({}); + setOpenFilterColumn(null); + }, []); + + // 🆕 데이터 요약 (Total Summaries) 설정 + // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } + const summaryConfig = useMemo(() => { + const config: Record = {}; + + // tableConfig에서 summary 설정 읽기 + if (tableConfig.summaries) { + tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { + config[summary.columnName] = { type: summary.type, label: summary.label }; + }); + } + + return config; + }, [tableConfig.summaries]); + + // 🆕 요약 데이터 계산 + const summaryData = useMemo(() => { + if (Object.keys(summaryConfig).length === 0 || data.length === 0) { + return null; + } + + const result: Record = {}; + + Object.entries(summaryConfig).forEach(([columnName, config]) => { + const values = data + .map((row) => { + const mappedColumnName = joinColumnMapping[columnName] || columnName; + const val = row[mappedColumnName]; + return typeof val === "number" ? val : parseFloat(val); + }) + .filter((v) => !isNaN(v)); + + let value: number | string = 0; + let label = config.label || ""; + + switch (config.type) { + case "sum": + value = values.reduce((acc, v) => acc + v, 0); + label = label || "합계"; + break; + case "avg": + value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0; + label = label || "평균"; + break; + case "count": + value = data.length; + label = label || "개수"; + break; + case "min": + value = values.length > 0 ? Math.min(...values) : 0; + label = label || "최소"; + break; + case "max": + value = values.length > 0 ? Math.max(...values) : 0; + label = label || "최대"; + break; + default: + value = 0; + } + + result[columnName] = { value, label }; + }); + + return result; + }, [data, summaryConfig, joinColumnMapping]); + + // 🆕 편집 취소 + const cancelEditing = useCallback(() => { + setEditingCell(null); + setEditingValue(""); + tableContainerRef.current?.focus(); + }, []); + + // 🆕 편집 저장 (즉시 저장 또는 배치 저장) + const saveEditing = useCallback(async () => { + if (!editingCell) return; + + const { rowIndex, columnName, originalValue } = editingCell; + const newValue = editingValue; + + // 값이 변경되지 않았으면 그냥 닫기 + if (String(originalValue ?? "") === newValue) { + setCellValidationError(rowIndex, columnName, null); // 에러 초기화 + cancelEditing(); + return; + } + + // 현재 행 데이터 가져오기 + const row = data[rowIndex]; + if (!row || !tableConfig.selectedTable) { + cancelEditing(); + return; + } + + // 🆕 유효성 검사 실행 + const validationError = validateValue(newValue === "" ? null : newValue, columnName, row); + if (validationError) { + setCellValidationError(rowIndex, columnName, validationError); + toast.error(validationError); + // 편집 상태 유지 (에러 수정 가능하도록) + return; + } + // 유효성 통과 시 에러 초기화 + setCellValidationError(rowIndex, columnName, null); + + // 기본 키 필드 찾기 (id 또는 첫 번째 컬럼) + const primaryKeyField = tableConfig.primaryKey || "id"; + const primaryKeyValue = row[primaryKeyField]; + + if (primaryKeyValue === undefined || primaryKeyValue === null) { + console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField); + cancelEditing(); + return; + } + + // 🆕 배치 모드: 변경사항을 pending에 저장 + if (editMode === "batch") { + const changeKey = `${rowIndex}-${columnName}`; + setPendingChanges((prev) => { + const newMap = new Map(prev); + newMap.set(changeKey, { + rowIndex, + columnName, + originalValue, + newValue: newValue === "" ? null : newValue, + primaryKeyValue, + }); + return newMap; + }); + + // 로컬 수정 데이터 업데이트 (화면 표시용) + setLocalEditedData((prev) => ({ + ...prev, + [rowIndex]: { + ...(prev[rowIndex] || {}), + [columnName]: newValue === "" ? null : newValue, + }, + })); + + console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 }); + cancelEditing(); + return; + } + + // 🆕 즉시 모드: 바로 저장 + try { + const { apiClient } = await import("@/lib/api/client"); + + await apiClient.put(`/dynamic-form/update-field`, { + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: primaryKeyValue, + updateField: columnName, + updateValue: newValue === "" ? null : newValue, + }); + + // 데이터 새로고침 트리거 + setRefreshTrigger((prev) => prev + 1); + + console.log("✅ 셀 편집 저장 완료:", { columnName, newValue }); + } catch (error) { + console.error("❌ 셀 편집 저장 실패:", error); + } + + cancelEditing(); + }, [editingCell, editingValue, data, tableConfig.selectedTable, tableConfig.primaryKey, cancelEditing, editMode, pendingChanges.size]); + + // 🆕 배치 저장: 모든 변경사항 한번에 저장 + const saveBatchChanges = useCallback(async () => { + if (pendingChanges.size === 0) { + toast.info("저장할 변경사항이 없습니다."); + return; + } + + try { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // 모든 변경사항 저장 + const savePromises = Array.from(pendingChanges.values()).map((change) => + apiClient.put(`/dynamic-form/update-field`, { + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: change.primaryKeyValue, + updateField: change.columnName, + updateValue: change.newValue, + }) + ); + + await Promise.all(savePromises); + + // 상태 초기화 + setPendingChanges(new Map()); + setLocalEditedData({}); + setRefreshTrigger((prev) => prev + 1); + + toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); + console.log("✅ 배치 저장 완료:", pendingChanges.size, "개"); + } catch (error) { + console.error("❌ 배치 저장 실패:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); + + // 🆕 배치 취소: 모든 변경사항 롤백 + const cancelBatchChanges = useCallback(() => { + if (pendingChanges.size === 0) return; + + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("변경사항이 취소되었습니다."); + console.log("🔄 배치 편집 취소"); + }, [pendingChanges.size]); + + // 🆕 특정 셀이 수정되었는지 확인 + const isCellModified = useCallback((rowIndex: number, columnName: string) => { + return pendingChanges.has(`${rowIndex}-${columnName}`); + }, [pendingChanges]); + + // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) + const getDisplayValue = useCallback((row: any, rowIndex: number, columnName: string) => { + const localValue = localEditedData[rowIndex]?.[columnName]; + if (localValue !== undefined) { + return localValue; + } + return row[columnName]; + }, [localEditedData]); + + // 🆕 유효성 검사 함수 + const validateValue = useCallback(( + value: any, + columnName: string, + row: any + ): string | null => { + // tableConfig.validation에서 컬럼별 규칙 가져오기 + const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; + if (!rules) return null; + + const strValue = value !== null && value !== undefined ? String(value) : ""; + const numValue = parseFloat(strValue); + + // 필수 검사 + if (rules.required && (!strValue || strValue.trim() === "")) { + return rules.customMessage || "필수 입력 항목입니다."; + } + + // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) + if (!strValue || strValue.trim() === "") return null; + + // 최소값 검사 + if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { + return rules.customMessage || `최소값은 ${rules.min}입니다.`; + } + + // 최대값 검사 + if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { + return rules.customMessage || `최대값은 ${rules.max}입니다.`; + } + + // 최소 길이 검사 + if (rules.minLength !== undefined && strValue.length < rules.minLength) { + return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; + } + + // 최대 길이 검사 + if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { + return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; + } + + // 패턴 검사 + if (rules.pattern && !rules.pattern.test(strValue)) { + return rules.customMessage || "입력 형식이 올바르지 않습니다."; + } + + // 커스텀 검증 + if (rules.validate) { + const customError = rules.validate(value, row); + if (customError) return customError; + } + + return null; + }, [tableConfig]); + + // 🆕 셀 유효성 에러 여부 확인 + const getCellValidationError = useCallback((rowIndex: number, columnName: string): string | null => { + return validationErrors.get(`${rowIndex}-${columnName}`) || null; + }, [validationErrors]); + + // 🆕 유효성 검사 에러 설정 + const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { + setValidationErrors((prev) => { + const newMap = new Map(prev); + const key = `${rowIndex}-${columnName}`; + if (error) { + newMap.set(key, error); + } else { + newMap.delete(key); + } + return newMap; + }); + }, []); + + // 🆕 모든 유효성 에러 초기화 + const clearAllValidationErrors = useCallback(() => { + setValidationErrors(new Map()); + }, []); + + // 🆕 Excel 내보내기 함수 + const exportToExcel = useCallback((exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 (선택된 행만 또는 전체) + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + // 선택된 행만 내보내기 + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } + + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 헤더 행 생성 + const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); + + // 데이터 행 생성 + const rows = exportData.map((row) => { + return exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + const value = row[mappedColumnName]; + + // 카테고리 매핑된 값 처리 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) { + return mapping.label; + } + } + + // null/undefined 처리 + if (value === null || value === undefined) { + return ""; + } + + return value; + }); + }); + + // 워크시트 생성 + const wsData = [headers, ...rows]; + const ws = XLSX.utils.aoa_to_sheet(wsData); + + // 컬럼 너비 자동 조정 + const colWidths = exportColumns.map((col, idx) => { + const headerLength = headers[idx]?.length || 10; + const maxDataLength = Math.max( + ...rows.map((row) => String(row[idx] ?? "").length) + ); + return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; + }); + ws["!cols"] = colWidths; + + // 워크북 생성 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); + + // 파일명 생성 + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + + // 파일 다운로드 + XLSX.writeFile(wb, fileName); + + toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); + console.log("✅ Excel 내보내기 완료:", fileName); + } catch (error) { + console.error("❌ Excel 내보내기 실패:", error); + toast.error("Excel 내보내기 중 오류가 발생했습니다."); + } + }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, tableLabel, tableConfig.selectedTable, getRowKey]); + + // 🆕 행 확장/축소 토글 + const toggleRowExpand = useCallback(async (rowKey: string, row: any) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowKey)) { + newSet.delete(rowKey); + } else { + newSet.add(rowKey); + // 상세 데이터 로딩 (아직 없는 경우) + if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { + loadDetailData(rowKey, row); + } + } + return newSet; + }); + }, [detailData, tableConfig]); + + // 🆕 상세 데이터 로딩 + const loadDetailData = useCallback(async (rowKey: string, row: any) => { + const masterDetailConfig = (tableConfig as any).masterDetail; + if (!masterDetailConfig?.detailTable) return; + + try { + const { apiClient } = await import("@/lib/api/client"); + + // masterKey 값 가져오기 + const masterKeyField = masterDetailConfig.masterKey || "id"; + const masterKeyValue = row[masterKeyField]; + + // 상세 테이블에서 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { + page: 1, + size: 100, + search: { + [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, + }, + autoFilter: true, + }); + + const details = response.data?.data?.data || []; + + setDetailData((prev) => ({ + ...prev, + [rowKey]: details, + })); + + console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); + } catch (error) { + console.error("❌ 상세 데이터 로딩 실패:", error); + setDetailData((prev) => ({ + ...prev, + [rowKey]: [], + })); + } + }, [tableConfig]); + + // 🆕 모든 행 확장/축소 + const expandAllRows = useCallback(() => { + if (expandedRows.size === filteredData.length) { + // 모두 축소 + setExpandedRows(new Set()); + } else { + // 모두 확장 + const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); + setExpandedRows(allKeys); + } + }, [expandedRows.size, filteredData, getRowKey]); + + // 🆕 Multi-Level Headers: Band 정보 계산 + const columnBandsInfo = useMemo(() => { + const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined; + if (!bands || bands.length === 0) return null; + + // 각 band의 시작 인덱스와 colspan 계산 + const bandInfo = bands.map((band) => { + const visibleBandColumns = band.columns.filter((colName) => + visibleColumns.some((vc) => vc.columnName === colName) + ); + + const startIndex = visibleColumns.findIndex( + (vc) => visibleBandColumns.includes(vc.columnName) + ); + + return { + caption: band.caption, + columns: visibleBandColumns, + colSpan: visibleBandColumns.length, + startIndex, + }; + }).filter((b) => b.colSpan > 0); + + // Band에 포함되지 않은 컬럼 찾기 + const bandedColumns = new Set(bands.flatMap((b) => b.columns)); + const unbandedColumns = visibleColumns + .map((vc, idx) => ({ columnName: vc.columnName, index: idx })) + .filter((c) => !bandedColumns.has(c.columnName)); + + return { + bands: bandInfo, + unbandedColumns, + hasBands: bandInfo.length > 0, + }; + }, [tableConfig, visibleColumns]); + + // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 + const loadCascadingOptions = useCallback(async ( + columnName: string, + parentColumnName: string, + parentValue: any + ) => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return; + + const cacheKey = `${columnName}_${parentValue}`; + + // 이미 로딩 중이면 스킵 + if (loadingCascading[cacheKey]) return; + + // 이미 캐시된 데이터가 있으면 스킵 + if (cascadingOptions[cacheKey]) return; + + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); + + try { + const { apiClient } = await import("@/lib/api/client"); + + // API에서 연계 옵션 로딩 + const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { + page: 1, + size: 1000, + search: { + [cascadingConfig.parentKeyField || parentColumnName]: parentValue, + }, + autoFilter: true, + }); + + const items = response.data?.data?.data || []; + const options = items.map((item: any) => ({ + value: item[cascadingConfig.valueField || "id"], + label: item[cascadingConfig.labelField || "name"], + })); + + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: options, + })); + + console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); + } catch (error) { + console.error("❌ Cascading options 로딩 실패:", error); + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: [], + })); + } finally { + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); + } + }, [tableConfig, cascadingOptions, loadingCascading]); + + // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 + const getCascadingOptions = useCallback((columnName: string, row: any): { value: string; label: string }[] => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return []; + + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue === undefined || parentValue === null) return []; + + const cacheKey = `${columnName}_${parentValue}`; + return cascadingOptions[cacheKey] || []; + }, [tableConfig, cascadingOptions]); + + // 🆕 Virtual Scrolling: 보이는 행 범위 계산 + const virtualScrollInfo = useMemo(() => { + if (!isVirtualScrollEnabled || filteredData.length === 0) { + return { + startIndex: 0, + endIndex: filteredData.length, + visibleData: filteredData, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + totalHeight: filteredData.length * ROW_HEIGHT, + }; + } + + const containerHeight = scrollContainerRef.current?.clientHeight || 600; + const totalRows = filteredData.length; + const totalHeight = totalRows * ROW_HEIGHT; + + // 현재 보이는 행 범위 계산 + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; + const endIndex = Math.min(totalRows, startIndex + visibleRowCount); + + return { + startIndex, + endIndex, + visibleData: filteredData.slice(startIndex, endIndex), + topSpacerHeight: startIndex * ROW_HEIGHT, + bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, + totalHeight, + }; + }, [isVirtualScrollEnabled, filteredData, scrollTop, ROW_HEIGHT, OVERSCAN]); + + // 🆕 Virtual Scrolling: 스크롤 핸들러 + const handleVirtualScroll = useCallback((e: React.UIEvent) => { + if (!isVirtualScrollEnabled) return; + setScrollTop(e.currentTarget.scrollTop); + }, [isVirtualScrollEnabled]); + + // 🆕 State Persistence: 통합 상태 저장 + const saveTableState = useCallback(() => { + if (!tableStateKey) return; + + const state = { + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters: Object.fromEntries( + Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]) + ), + pageSize: localPageSize, + timestamp: Date.now(), + }; + + try { + localStorage.setItem(tableStateKey, JSON.stringify(state)); + console.log("✅ 테이블 상태 저장:", tableStateKey); + } catch (error) { + console.error("❌ 테이블 상태 저장 실패:", error); + } + }, [tableStateKey, columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters, localPageSize]); + + // 🆕 State Persistence: 통합 상태 복원 + const loadTableState = useCallback(() => { + if (!tableStateKey) return; + + try { + const saved = localStorage.getItem(tableStateKey); + if (!saved) return; + + const state = JSON.parse(saved); + + if (state.columnWidths) setColumnWidths(state.columnWidths); + if (state.columnOrder) setColumnOrder(state.columnOrder); + if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); + if (state.sortDirection) setSortDirection(state.sortDirection); + if (state.groupByColumns) setGroupByColumns(state.groupByColumns); + if (state.frozenColumns) setFrozenColumns(state.frozenColumns); + if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); + if (state.headerFilters) { + const filters: Record> = {}; + Object.entries(state.headerFilters).forEach(([key, values]) => { + filters[key] = new Set(values as string[]); + }); + setHeaderFilters(filters); + } + + console.log("✅ 테이블 상태 복원:", tableStateKey); + } catch (error) { + console.error("❌ 테이블 상태 복원 실패:", error); + } + }, [tableStateKey]); + + // 🆕 State Persistence: 상태 초기화 + const resetTableState = useCallback(() => { + if (!tableStateKey) return; + + try { + localStorage.removeItem(tableStateKey); + setColumnWidths({}); + setColumnOrder([]); + setSortColumn(null); + setSortDirection("asc"); + setGroupByColumns([]); + setFrozenColumns([]); + setShowGridLines(true); + setHeaderFilters({}); + toast.success("테이블 설정이 초기화되었습니다."); + console.log("✅ 테이블 상태 초기화:", tableStateKey); + } catch (error) { + console.error("❌ 테이블 상태 초기화 실패:", error); + } + }, [tableStateKey]); + + // 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원 + useEffect(() => { + loadTableState(); + }, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지) + + // 🆕 Real-Time Updates: WebSocket 연결 + const connectWebSocket = useCallback(() => { + if (!isRealTimeEnabled || !tableConfig.selectedTable) return; + + const wsUrl = (tableConfig as any).wsUrl || + `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; + + try { + setWsConnectionStatus("connecting"); + wsRef.current = new WebSocket(wsUrl); + + wsRef.current.onopen = () => { + setWsConnectionStatus("connected"); + console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable); + }; + + wsRef.current.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log("📨 WebSocket 메시지 수신:", message); + + switch (message.type) { + case "insert": + // 새 데이터 추가 + setRefreshTrigger((prev) => prev + 1); + toast.info("새 데이터가 추가되었습니다."); + break; + case "update": + // 데이터 업데이트 + setRefreshTrigger((prev) => prev + 1); + toast.info("데이터가 업데이트되었습니다."); + break; + case "delete": + // 데이터 삭제 + setRefreshTrigger((prev) => prev + 1); + toast.info("데이터가 삭제되었습니다."); + break; + case "refresh": + // 전체 새로고침 + setRefreshTrigger((prev) => prev + 1); + break; + default: + console.log("알 수 없는 메시지 타입:", message.type); + } + } catch (error) { + console.error("WebSocket 메시지 파싱 오류:", error); + } + }; + + wsRef.current.onclose = () => { + setWsConnectionStatus("disconnected"); + console.log("🔌 WebSocket 연결 종료"); + + // 자동 재연결 (5초 후) + if (isRealTimeEnabled) { + reconnectTimeoutRef.current = setTimeout(() => { + console.log("🔄 WebSocket 재연결 시도..."); + connectWebSocket(); + }, 5000); + } + }; + + wsRef.current.onerror = (error) => { + console.error("❌ WebSocket 오류:", error); + setWsConnectionStatus("disconnected"); + }; + } catch (error) { + console.error("WebSocket 연결 실패:", error); + setWsConnectionStatus("disconnected"); + } + }, [isRealTimeEnabled, tableConfig.selectedTable]); + + // 🆕 Real-Time Updates: 연결 관리 + useEffect(() => { + if (isRealTimeEnabled) { + connectWebSocket(); + } + + return () => { + // 정리 + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [isRealTimeEnabled, tableConfig.selectedTable]); + + // 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스) + useEffect(() => { + const timeoutId = setTimeout(() => { + saveTableState(); + }, 1000); // 1초 후 저장 (디바운스) + + return () => clearTimeout(timeoutId); + }, [columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters]); + + // 🆕 Clipboard: 선택된 데이터 복사 + const handleCopy = useCallback(async () => { + try { + // 선택된 행 데이터 가져오기 + let copyData: any[]; + + if (selectedRows.size > 0) { + // 선택된 행만 + copyData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } else if (focusedCell) { + // 포커스된 셀만 + const row = filteredData[focusedCell.rowIndex]; + if (row) { + const column = visibleColumns[focusedCell.colIndex]; + const value = row[column?.columnName]; + await navigator.clipboard.writeText(String(value ?? "")); + toast.success("셀 복사됨"); + return; + } + return; + } else { + toast.info("복사할 데이터를 선택해주세요."); + return; + } + + // TSV 형식으로 변환 (탭으로 구분) + const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); + const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); + const rows = copyData.map((row) => + exportColumns.map((c) => { + const value = row[c.columnName]; + return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; + }).join("\t") + ); + + const tsvContent = [headers.join("\t"), ...rows].join("\n"); + await navigator.clipboard.writeText(tsvContent); + + toast.success(`${copyData.length}행 복사됨`); + console.log("✅ 클립보드 복사:", copyData.length, "행"); + } catch (error) { + console.error("❌ 클립보드 복사 실패:", error); + toast.error("복사 실패"); + } + }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); + + // 🆕 전체 행 선택 + const handleSelectAllRows = useCallback(() => { + if (selectedRows.size === filteredData.length) { + // 전체 해제 + setSelectedRows(new Set()); + setIsAllSelected(false); + } else { + // 전체 선택 + const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); + setSelectedRows(allKeys); + setIsAllSelected(true); + } + }, [selectedRows.size, filteredData, getRowKey]); + + // 🆕 Context Menu: 열기 + const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => { + e.preventDefault(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + rowIndex, + colIndex, + row, + }); + }, []); + + // 🆕 Context Menu: 닫기 + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + // 🆕 Context Menu: 외부 클릭 시 닫기 + useEffect(() => { + if (contextMenu) { + const handleClick = () => closeContextMenu(); + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + } + }, [contextMenu, closeContextMenu]); + + // 🆕 Search Panel: 통합 검색 실행 + const executeGlobalSearch = useCallback((term: string) => { + if (!term.trim()) { + setSearchHighlights(new Set()); + return; + } + + const lowerTerm = term.toLowerCase(); + const highlights = new Set(); + + filteredData.forEach((row, rowIndex) => { + visibleColumns.forEach((col, colIndex) => { + const value = row[col.columnName]; + if (value !== null && value !== undefined) { + const strValue = String(value).toLowerCase(); + if (strValue.includes(lowerTerm)) { + highlights.add(`${rowIndex}-${colIndex}`); + } + } + }); + }); + + setSearchHighlights(highlights); + + // 첫 번째 검색 결과로 포커스 이동 + if (highlights.size > 0) { + const firstHighlight = Array.from(highlights)[0]; + const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + toast.success(`${highlights.size}개 검색 결과`); + } else { + toast.info("검색 결과가 없습니다"); + } + }, [filteredData, visibleColumns]); + + // 🆕 Search Panel: 다음 검색 결과로 이동 + const goToNextSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + + const highlightArray = Array.from(searchHighlights).sort((a, b) => { + const [aRow, aCol] = a.split("-").map(Number); + const [bRow, bCol] = b.split("-").map(Number); + if (aRow !== bRow) return aRow - bRow; + return aCol - bCol; + }); + + if (!focusedCell) { + const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + return; + } + + const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; + const currentIndex = highlightArray.indexOf(currentKey); + const nextIndex = (currentIndex + 1) % highlightArray.length; + const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + }, [searchHighlights, focusedCell]); + + // 🆕 Search Panel: 이전 검색 결과로 이동 + const goToPrevSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + + const highlightArray = Array.from(searchHighlights).sort((a, b) => { + const [aRow, aCol] = a.split("-").map(Number); + const [bRow, bCol] = b.split("-").map(Number); + if (aRow !== bRow) return aRow - bRow; + return aCol - bCol; + }); + + if (!focusedCell) { + const lastIdx = highlightArray.length - 1; + const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + return; + } + + const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; + const currentIndex = highlightArray.indexOf(currentKey); + const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1; + const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + }, [searchHighlights, focusedCell]); + + // 🆕 Search Panel: 검색 초기화 + const clearGlobalSearch = useCallback(() => { + setGlobalSearchTerm(""); + setSearchHighlights(new Set()); + setIsSearchPanelOpen(false); + }, []); + + // 🆕 Filter Builder: 조건 추가 + const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: [ + ...group.conditions, + { + id: `cond-${Date.now()}`, + column: defaultColumn || "", + operator: "contains" as const, + value: "", + }, + ], + } + : group + ) + ); + }, []); + + // 🆕 Filter Builder: 조건 삭제 + const removeFilterCondition = useCallback((groupId: string, conditionId: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: group.conditions.filter((c) => c.id !== conditionId), + } + : group + ) + ); + }, []); + + // 🆕 Filter Builder: 조건 업데이트 + const updateFilterCondition = useCallback( + (groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: group.conditions.map((c) => + c.id === conditionId ? { ...c, [field]: value } : c + ), + } + : group + ) + ); + }, + [] + ); + + // 🆕 Filter Builder: 그룹 추가 + const addFilterGroup = useCallback((defaultColumn?: string) => { + setFilterGroups((prev) => [ + ...prev, + { + id: `group-${Date.now()}`, + logic: "AND" as const, + conditions: [ + { + id: `cond-${Date.now()}`, + column: defaultColumn || "", + operator: "contains" as const, + value: "", + }, + ], + }, + ]); + }, []); + + // 🆕 Filter Builder: 그룹 삭제 + const removeFilterGroup = useCallback((groupId: string) => { + setFilterGroups((prev) => prev.filter((g) => g.id !== groupId)); + }, []); + + // 🆕 Filter Builder: 그룹 로직 변경 + const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { + setFilterGroups((prev) => + prev.map((group) => (group.id === groupId ? { ...group, logic } : group)) + ); + }, []); + + // 🆕 Filter Builder: 필터 적용 + const applyFilterBuilder = useCallback(() => { + // 유효한 조건 개수 계산 + let validConditions = 0; + filterGroups.forEach((group) => { + group.conditions.forEach((cond) => { + if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { + validConditions++; + } + }); + }); + setActiveFilterCount(validConditions); + setIsFilterBuilderOpen(false); + toast.success(`${validConditions}개 필터 조건 적용됨`); + }, [filterGroups]); + + // 🆕 Filter Builder: 필터 초기화 + const clearFilterBuilder = useCallback(() => { + setFilterGroups([]); + setActiveFilterCount(0); + toast.info("필터 초기화됨"); + }, []); + + // 🆕 Filter Builder: 조건 평가 함수 + const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => { + const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; + const condValue = condition.value.toLowerCase(); + + switch (condition.operator) { + case "equals": + return strValue === condValue; + case "notEquals": + return strValue !== condValue; + case "contains": + return strValue.includes(condValue); + case "notContains": + return !strValue.includes(condValue); + case "startsWith": + return strValue.startsWith(condValue); + case "endsWith": + return strValue.endsWith(condValue); + case "greaterThan": + return parseFloat(strValue) > parseFloat(condValue); + case "lessThan": + return parseFloat(strValue) < parseFloat(condValue); + case "greaterOrEqual": + return parseFloat(strValue) >= parseFloat(condValue); + case "lessOrEqual": + return parseFloat(strValue) <= parseFloat(condValue); + case "isEmpty": + return strValue === "" || value === null || value === undefined; + case "isNotEmpty": + return strValue !== "" && value !== null && value !== undefined; + default: + return true; + } + }, []); + + // 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인 + const rowPassesFilterBuilder = useCallback( + (row: any): boolean => { + if (filterGroups.length === 0) return true; + + // 모든 그룹이 AND로 연결됨 (그룹 간) + return filterGroups.every((group) => { + const validConditions = group.conditions.filter( + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + ); + if (validConditions.length === 0) return true; + + if (group.logic === "AND") { + return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); + } else { + return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); + } + }); + }, + [filterGroups, evaluateCondition] + ); + + // 🆕 컬럼 드래그 시작 + const handleColumnDragStart = useCallback((e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled) return; + + setDraggedColumnIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${index}`); + }, [isColumnDragEnabled]); + + // 🆕 컬럼 드래그 오버 + const handleColumnDragOver = useCallback((e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled || draggedColumnIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedColumnIndex) { + setDropTargetColumnIndex(index); + } + }, [isColumnDragEnabled, draggedColumnIndex]); + + // 🆕 컬럼 드래그 종료 + const handleColumnDragEnd = useCallback(() => { + setDraggedColumnIndex(null); + setDropTargetColumnIndex(null); + }, []); + + // 🆕 컬럼 드롭 + const handleColumnDrop = useCallback((e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + handleColumnDragEnd(); + return; + } + + // 컬럼 순서 변경 + const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; + const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); + newOrder.splice(targetIndex, 0, movedColumn); + + setColumnOrder(newOrder); + toast.info("컬럼 순서가 변경되었습니다."); + console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); + + handleColumnDragEnd(); + }, [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd]); + + // 🆕 행 드래그 시작 + const handleRowDragStart = useCallback((e: React.DragEvent, index: number) => { + if (!isDragEnabled) return; + + setDraggedRowIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + + // 드래그 이미지 설정 (반투명) + const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; + dragImage.style.opacity = "0.5"; + dragImage.style.position = "absolute"; + dragImage.style.top = "-1000px"; + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 0, 0); + setTimeout(() => document.body.removeChild(dragImage), 0); + }, [isDragEnabled]); + + // 🆕 행 드래그 오버 + const handleRowDragOver = useCallback((e: React.DragEvent, index: number) => { + if (!isDragEnabled || draggedRowIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedRowIndex) { + setDropTargetIndex(index); + } + }, [isDragEnabled, draggedRowIndex]); + + // 🆕 행 드래그 종료 + const handleRowDragEnd = useCallback(() => { + setDraggedRowIndex(null); + setDropTargetIndex(null); + }, []); + + // 🆕 행 드롭 + const handleRowDrop = useCallback(async (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { + handleRowDragEnd(); + return; + } + + try { + // 로컬 데이터 재정렬 + const newData = [...filteredData]; + const [movedRow] = newData.splice(draggedRowIndex, 1); + newData.splice(targetIndex, 0, movedRow); + + // 서버에 순서 저장 (order_index 필드가 있는 경우) + const orderField = (tableConfig as any).orderField || "order_index"; + const hasOrderField = newData[0] && orderField in newData[0]; + + if (hasOrderField && tableConfig.selectedTable) { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // 영향받는 행들의 순서 업데이트 + const updates = newData.map((row, idx) => ({ + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: row[primaryKeyField], + updateField: orderField, + updateValue: idx + 1, + })); + + // 배치 업데이트 + await Promise.all( + updates.map((update) => + apiClient.put(`/dynamic-form/update-field`, update) + ) + ); + + toast.success("순서가 변경되었습니다."); + setRefreshTrigger((prev) => prev + 1); + } else { + // 로컬에서만 순서 변경 (저장 안함) + toast.info("순서가 변경되었습니다. (로컬만)"); + } + + console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); + } catch (error) { + console.error("❌ 행 순서 변경 실패:", error); + toast.error("순서 변경 중 오류가 발생했습니다."); + } + + handleRowDragEnd(); + }, [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd]); + + // 🆕 PDF 내보내기 (인쇄용 HTML 생성) + const exportToPdf = useCallback((exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } + + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 인쇄용 HTML 생성 + const printContent = ` + + + + + ${tableLabel || tableConfig.selectedTable || "데이터"} + + + +

${tableLabel || tableConfig.selectedTable || "데이터 목록"}

+
+ 출력일: ${new Date().toLocaleDateString("ko-KR")} | + 총 ${exportData.length}건 +
+ + + + ${exportColumns.map((col) => ``).join("")} + + + + ${exportData.map((row) => ` + + ${exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + let value = row[mappedColumnName]; + + // 카테고리 매핑 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) value = mapping.label; + } + + const meta = columnMeta[col.columnName]; + const inputType = meta?.inputType || (col as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ``; + }).join("")} + + `).join("")} + +
${columnLabels[col.columnName] || col.columnName}
${value ?? ""}
+ + + `; + + // 새 창에서 인쇄 + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(printContent); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + toast.success("인쇄 창이 열렸습니다."); + } else { + toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); + } + } catch (error) { + console.error("❌ PDF 내보내기 실패:", error); + toast.error("PDF 내보내기 중 오류가 발생했습니다."); + } + }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, columnMeta, tableLabel, tableConfig.selectedTable, getRowKey]); + + // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) + const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + saveEditing(); + break; + case "Escape": + e.preventDefault(); + cancelEditing(); + break; + case "Tab": + e.preventDefault(); + saveEditing(); + // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 + break; + } + }, [saveEditing, cancelEditing]); + + // 🆕 편집 입력 필드가 나타나면 자동 포커스 + useEffect(() => { + if (editingCell && editInputRef.current) { + editInputRef.current.focus(); + // select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가) + if (typeof editInputRef.current.select === "function") { + editInputRef.current.select(); + } + } + }, [editingCell]); + + // 🆕 포커스된 셀로 스크롤 + useEffect(() => { + if (focusedCell && tableContainerRef.current) { + const focusedCellElement = tableContainerRef.current.querySelector( + `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]` + ) as HTMLElement; + + if (focusedCellElement) { + focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + } + }, [focusedCell]); + // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) const handleClick = (e: React.MouseEvent) => { @@ -1791,62 +3552,9 @@ export const TableListComponent: React.FC = ({ }; // ======================================== - // 컬럼 관련 + // 컬럼 관련 (visibleColumns는 상단에서 정의됨) // ======================================== - const visibleColumns = useMemo(() => { - let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - - // columnVisibility가 있으면 가시성 적용 - if (columnVisibility.length > 0) { - cols = cols.filter((col) => { - const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); - return visibilityConfig ? visibilityConfig.visible : true; - }); - } - - // 체크박스 컬럼 (나중에 위치 결정) - // 기본값: enabled가 undefined면 true로 처리 - let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled ?? true) { - checkboxCol = { - columnName: "__checkbox__", - displayName: "", - visible: true, - sortable: false, - searchable: false, - width: 50, - align: "center", - order: -1, - }; - } - - // columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외) - if (columnOrder.length > 0) { - const orderedCols = columnOrder - .map((colName) => cols.find((c) => c.columnName === colName)) - .filter(Boolean) as ColumnConfig[]; - - // columnOrder에 없는 새로운 컬럼들 추가 - const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName)); - - cols = [...orderedCols, ...remainingCols]; - } else { - cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0)); - } - - // 체크박스를 맨 앞 또는 맨 뒤에 추가 - if (checkboxCol) { - if (tableConfig.checkbox?.position === "right") { - cols = [...cols, checkboxCol]; - } else { - cols = [checkboxCol, ...cols]; - } - } - - return cols; - }, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]); - // 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달 const lastColumnOrderRef = useRef(""); @@ -1917,6 +3625,231 @@ export const TableListComponent: React.FC = ({ ); }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 + // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) + const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { + // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) + if (editingCell) return; + + if (!focusedCell || data.length === 0) return; + + const { rowIndex, colIndex } = focusedCell; + const maxRowIndex = data.length - 1; + const maxColIndex = visibleColumns.length - 1; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); + } + break; + case "ArrowDown": + e.preventDefault(); + if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } + break; + case "ArrowRight": + e.preventDefault(); + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } + break; + case "Enter": + e.preventDefault(); + // 현재 행 선택/해제 + const enterRow = data[rowIndex]; + if (enterRow) { + const rowKey = getRowKey(enterRow, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + handleRowSelection(rowKey, !isCurrentlySelected); + } + break; + case " ": // Space + e.preventDefault(); + // 체크박스 토글 + const spaceRow = data[rowIndex]; + if (spaceRow) { + const currentRowKey = getRowKey(spaceRow, rowIndex); + const isChecked = selectedRows.has(currentRowKey); + handleRowSelection(currentRowKey, !isChecked); + } + break; + case "F2": + // 🆕 F2: 편집 모드 진입 + e.preventDefault(); + { + const col = visibleColumns[colIndex]; + if (col && col.columnName !== "__checkbox__") { + const row = data[rowIndex]; + const mappedCol = joinColumnMapping[col.columnName] || col.columnName; + const val = row?.[mappedCol]; + setEditingCell({ + rowIndex, + colIndex, + columnName: col.columnName, + originalValue: val + }); + setEditingValue(val !== null && val !== undefined ? String(val) : ""); + } + } + break; + case "b": + case "B": + // 🆕 Ctrl+B: 배치 편집 모드 토글 + if (e.ctrlKey) { + e.preventDefault(); + setEditMode((prev) => { + const newMode = prev === "immediate" ? "batch" : "immediate"; + if (newMode === "immediate" && pendingChanges.size > 0) { + // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 + const confirmDiscard = window.confirm( + `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?` + ); + if (confirmDiscard) { + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("배치 편집 모드 종료"); + return "immediate"; + } + return "batch"; + } + toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); + return newMode; + }); + } + break; + case "s": + case "S": + // 🆕 Ctrl+S: 배치 저장 + if (e.ctrlKey && editMode === "batch") { + e.preventDefault(); + saveBatchChanges(); + } + break; + case "c": + case "C": + // 🆕 Ctrl+C: 선택된 행/셀 복사 + if (e.ctrlKey) { + e.preventDefault(); + handleCopy(); + } + break; + case "v": + case "V": + // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) + if (e.ctrlKey && editingCell) { + // 기본 동작 허용 (input에서 처리) + } + break; + case "a": + case "A": + // 🆕 Ctrl+A: 전체 선택 + if (e.ctrlKey) { + e.preventDefault(); + handleSelectAllRows(); + } + break; + case "f": + case "F": + // 🆕 Ctrl+F: 통합 검색 패널 열기 + if (e.ctrlKey) { + e.preventDefault(); + setIsSearchPanelOpen(true); + } + break; + case "F3": + // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+Home: 첫 번째 셀로 + setFocusedCell({ rowIndex: 0, colIndex: 0 }); + } else { + // Home: 현재 행의 첫 번째 셀로 + setFocusedCell({ rowIndex, colIndex: 0 }); + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+End: 마지막 셀로 + setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); + } else { + // End: 현재 행의 마지막 셀로 + setFocusedCell({ rowIndex, colIndex: maxColIndex }); + } + break; + case "PageUp": + e.preventDefault(); + // 10행 위로 + setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); + break; + case "PageDown": + e.preventDefault(); + // 10행 아래로 + setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); + break; + case "Escape": + e.preventDefault(); + // 포커스 해제 + setFocusedCell(null); + break; + case "Tab": + e.preventDefault(); + if (e.shiftKey) { + // Shift+Tab: 이전 셀 + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } else if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); + } + } else { + // Tab: 다음 셀 + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } else if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); + } + } + break; + default: + // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + const column = visibleColumns[colIndex]; + if (column && column.columnName !== "__checkbox__") { + e.preventDefault(); + // 편집 시작 (현재 키를 초기값으로) + const row = data[rowIndex]; + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const value = row?.[mappedColumnName]; + + setEditingCell({ + rowIndex, + colIndex, + columnName: column.columnName, + originalValue: value + }); + setEditingValue(e.key); // 입력한 키로 시작 + } + } + break; + } + }, [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection]); + const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; if (column.width) return column.width; @@ -2411,14 +4344,42 @@ export const TableListComponent: React.FC = ({ groupValues[col] = items[0]?.[col]; }); + // 🆕 그룹별 소계 계산 + const groupSummary: Record = {}; + + // 숫자형 컬럼에 대해 소계 계산 + (tableConfig.columns || []).forEach((col: { columnName: string }) => { + if (col.columnName === "__checkbox__") return; + + const colMeta = columnMeta?.[col.columnName]; + const inputType = colMeta?.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + if (isNumeric) { + const values = items + .map((item) => parseFloat(item[col.columnName])) + .filter((v) => !isNaN(v)); + + if (values.length > 0) { + const sum = values.reduce((a, b) => a + b, 0); + groupSummary[col.columnName] = { + sum, + avg: sum / values.length, + count: values.length, + }; + } + } + }); + return { groupKey, groupValues, items, count: items.length, + summary: groupSummary, // 🆕 그룹별 소계 }; }); - }, [data, groupByColumns, columnLabels, columnMeta]); + }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); // 저장된 그룹 설정 불러오기 useEffect(() => { @@ -2632,19 +4593,81 @@ export const TableListComponent: React.FC = ({
- {/* 우측 새로고침 버튼 */} - + {/* 우측 버튼 그룹 */} +
+ {/* 🆕 내보내기 버튼 (Excel/PDF) */} + + + + + +
+
Excel
+ + +
+
PDF/인쇄
+ + +
+ + + + {/* 새로고침 버튼 */} + +
); - }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]); + }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); // ======================================== // 렌더링 @@ -2744,6 +4767,236 @@ export const TableListComponent: React.FC = ({
{/* 필터 헤더는 TableSearchWidget으로 이동 */} + {/* 🆕 DevExpress 스타일 기능 툴바 */} +
+ {/* 편집 모드 토글 */} +
+ +
+ + {/* 내보내기 버튼들 */} +
+ + +
+ + {/* 복사 버튼 */} +
+ +
+ + {/* 선택 정보 */} + {selectedRows.size > 0 && ( +
+ + {selectedRows.size}개 선택됨 + + +
+ )} + + {/* 🆕 통합 검색 패널 */} +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); + } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + } + }} + placeholder="검색어 입력... (Enter)" + className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" + autoFocus + /> + {searchHighlights.size > 0 && ( + + {searchHighlights.size}개 + + )} + + + +
+ ) : ( + + )} +
+ + {/* 🆕 Filter Builder (고급 필터) 버튼 */} +
+ + {activeFilterCount > 0 && ( + + )} +
+ + {/* 새로고침 */} +
+ +
+
+ + {/* 🆕 배치 편집 툴바 */} + {(editMode === "batch" || pendingChanges.size > 0) && ( +
+
+ + 배치 편집 모드 + + {pendingChanges.size > 0 && ( + + {pendingChanges.size}개 변경사항 + + )} +
+
+ + +
+
+ )} + {/* 그룹 표시 배지 */} {groupByColumns.length > 0 && (
@@ -2770,17 +5023,23 @@ export const TableListComponent: React.FC = ({
)} - {/* 테이블 컨테이너 */} + {/* 테이블 컨테이너 - 키보드 네비게이션 지원 */}
{/* 스크롤 영역 */}
= ({ height: "100%", overflow: "auto", }} + onScroll={handleVirtualScroll} > {/* 테이블 */} = ({ backgroundColor: "hsl(var(--background))", }} > + {/* 🆕 Multi-Level Headers (Column Bands) */} + {columnBandsInfo?.hasBands && ( + + {visibleColumns.map((column, colIdx) => { + // 이 컬럼이 속한 band 찾기 + const band = columnBandsInfo.bands.find( + (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx + ); + + // band의 첫 번째 컬럼인 경우에만 렌더링 + if (band) { + return ( + + ); + } + + // band에 속하지 않은 컬럼 (개별 표시) + const isInAnyBand = columnBandsInfo.bands.some( + (b) => b.columns.includes(column.columnName) + ); + if (!isInAnyBand) { + return ( + + ); + } + + // band의 중간 컬럼은 렌더링하지 않음 + return null; + })} + + )} = ({ } } + // 🆕 Column Reordering 상태 + const isColumnDragging = draggedColumnIndex === columnIndex; + const isColumnDropTarget = dropTargetColumnIndex === columnIndex; + return ( ))} + {/* 🆕 그룹별 소계 행 */} + {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( + + {visibleColumns.map((column, colIndex) => { + const summary = group.summary?.[column.columnName]; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || (column as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + if (colIndex === 0 && column.columnName === "__checkbox__") { + return ( + + ); + } + + if (colIndex === 0 && column.columnName !== "__checkbox__") { + return ( + + ); + } + + if (summary) { + return ( + + ); + } + + return + )} ); }) ) : ( - // 일반 렌더링 (그룹 없음) - filteredData.map((row, index) => ( - handleRowClick(row, index, e)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const cellValue = row[mappedColumnName]; + // 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원 + <> + {/* 🆕 Virtual Scrolling: Top Spacer */} + {isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && ( + + + )} + {/* 데이터 행 렌더링 */} + {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : filteredData).map((row, idx) => { + // Virtual Scrolling에서는 실제 인덱스 계산 + const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; + const rowKey = getRowKey(row, index); + const isRowSelected = selectedRows.has(rowKey); + const isRowFocused = focusedCell?.rowIndex === index; + + // 🆕 Drag & Drop 상태 + const isDragging = draggedRowIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + handleRowClick(row, index, e)} + role="row" + aria-selected={isRowSelected} + // 🆕 Drag & Drop 이벤트 + draggable={isDragEnabled} + onDragStart={(e) => handleRowDragStart(e, index)} + onDragOver={(e) => handleRowDragOver(e, index)} + onDragEnd={handleRowDragEnd} + onDrop={(e) => handleRowDrop(e, index)} + > + {visibleColumns.map((column, colIndex) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 + const cellValue = editMode === "batch" + ? getDisplayValue(row, index, mappedColumnName) + : row[mappedColumnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 셀 포커스 상태 + const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; + + // 🆕 배치 편집: 수정된 셀 여부 + const isModified = isCellModified(index, mappedColumnName); + + // 🆕 유효성 검사 에러 + const cellValidationError = getCellValidationError(index, mappedColumnName); + + // 🆕 검색 하이라이트 여부 + const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; + // 틀고정된 컬럼의 left 위치 계산 + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } } - } - return ( - - ); - })} - - )) + return ( + + ); + })} + + ); + })} + {/* 🆕 Virtual Scrolling: Bottom Spacer */} + {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( + + + )} + )} + + {/* 🆕 데이터 요약 (Total Summaries) */} + {summaryData && Object.keys(summaryData).length > 0 && ( + + + {visibleColumns.map((column, colIndex) => { + const summary = summaryData[column.columnName]; + const columnWidth = columnWidths[column.columnName]; + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 틀고정된 컬럼의 left 위치 계산 + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } + } + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || (column as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + ); + })} + + + )}
+ {band.caption} + + {columnLabels[column.columnName] || column.columnName} +
= ({ column.columnName !== "__checkbox__" && "hover:bg-muted/70 cursor-pointer transition-colors", isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", + // 🆕 Column Reordering 스타일 + isColumnDragEnabled && column.columnName !== "__checkbox__" && "cursor-grab active:cursor-grabbing", + isColumnDragging && "opacity-50 bg-primary/20", + isColumnDropTarget && "border-l-4 border-l-primary", )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -2855,6 +5169,12 @@ export const TableListComponent: React.FC = ({ backgroundColor: "hsl(var(--muted))", ...(isFrozen && { left: `${leftPosition}px` }), }} + // 🆕 Column Reordering 이벤트 + draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"} + onDragStart={(e) => handleColumnDragStart(e, columnIndex)} + onDragOver={(e) => handleColumnDragOver(e, columnIndex)} + onDragEnd={handleColumnDragEnd} + onDrop={(e) => handleColumnDrop(e, columnIndex)} onClick={() => { if (isResizing.current) return; if (column.sortable !== false && column.columnName !== "__checkbox__") { @@ -2865,11 +5185,81 @@ export const TableListComponent: React.FC = ({ {column.columnName === "__checkbox__" ? ( renderCheckboxHeader() ) : ( -
+
{columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )} + {/* 🆕 헤더 필터 버튼 */} + {tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && ( + setOpenFilterColumn(open ? column.columnName : null)} + > + + + + e.stopPropagation()} + > +
+
+ 필터: {columnLabels[column.columnName] || column.displayName} + {headerFilters[column.columnName]?.size > 0 && ( + + )} +
+
+ {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { + const isSelected = headerFilters[column.columnName]?.has(val); + return ( +
toggleHeaderFilter(column.columnName, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( +
+ ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 +
+ )} +
+
+
+
+ )}
)} {/* 리사이즈 핸들 (체크박스 제외) */} @@ -3073,71 +5463,317 @@ export const TableListComponent: React.FC = ({ })}
+ 소계 + + 소계 ({group.count}건) + + {summary.sum.toLocaleString()} + ; + })} +
+
- {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} -
handleCellClick(index, colIndex, e)} + onDoubleClick={() => handleCellDoubleClick(index, colIndex, column.columnName, cellValue)} + onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} + role="gridcell" + tabIndex={isCellFocused ? 0 : -1} + > + {/* 🆕 인라인 편집 모드 */} + {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex ? ( + // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 + (() => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[column.columnName]; + const options = cascadingConfig ? getCascadingOptions(column.columnName, row) : []; + + // 부모 값이 변경되면 옵션 로딩 + if (cascadingConfig && options.length === 0) { + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue !== undefined && parentValue !== null) { + loadCascadingOptions(column.columnName, cascadingConfig.parentColumn, parentValue); + } + } + + // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 + const colMeta = columnMeta[column.columnName]; + const isCategoryType = colMeta?.inputType === "category" || colMeta?.inputType === "code"; + const hasCategoryOptions = categoryMappings[column.columnName] && Object.keys(categoryMappings[column.columnName]).length > 0; + + if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { + const selectOptions = cascadingConfig + ? options + : Object.entries(categoryMappings[column.columnName] || {}).map(([value, info]) => ({ + value, + label: info.label, + })); + + return ( + + ); + } + + // 일반 입력 필드 + return ( + setEditingValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={saveEditing} + className="w-full h-full px-2 py-1 sm:px-4 sm:py-1.5 text-xs sm:text-sm border-2 border-primary bg-background focus:outline-none" + style={{ + textAlign: isNumeric ? "right" : column.align || "left", + }} + /> + ); + })() + ) : column.columnName === "__checkbox__" ? ( + renderCheckboxCell(row, index) + ) : ( + formatCellValue(cellValue, column, row) + )} +
+
+ {summary ? ( +
+ {summary.label} + + {typeof summary.value === "number" + ? summary.value.toLocaleString("ko-KR", { + maximumFractionDigits: 2, + }) + : summary.value} + +
+ ) : colIndex === 0 ? ( + 요약 + ) : null} +
@@ -3226,6 +5862,284 @@ export const TableListComponent: React.FC = ({ + {/* 🆕 Context Menu (우클릭 메뉴) */} + {contextMenu && ( +
e.stopPropagation()} + > +
+ {/* 셀 복사 */} + + + {/* 행 복사 */} + + +
+ + {/* 셀 편집 */} + + + {/* 행 선택/해제 */} + + +
+ + {/* 행 삭제 */} + +
+
+ )} + + {/* 🆕 Filter Builder 모달 */} + + + + 고급 필터 + + 여러 조건을 조합하여 데이터를 필터링합니다. + + + +
+ {filterGroups.length === 0 ? ( +
+ 필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요. +
+ ) : ( + filterGroups.map((group, groupIndex) => ( +
+
+
+ 조건 그룹 {groupIndex + 1} + +
+ +
+ +
+ {group.conditions.map((condition) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */} + {condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && ( + updateFilterCondition(group.id, condition.id, "value", e.target.value)} + placeholder="값 입력" + className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs" + /> + )} + + {/* 조건 삭제 */} + +
+ ))} +
+ + {/* 조건 추가 버튼 */} + +
+ )) + )} + + {/* 그룹 추가 버튼 */} + +
+ + + + + + +
+
+ {/* 테이블 옵션 모달 */} Date: Fri, 5 Dec 2025 17:42:35 +0900 Subject: [PATCH 11/24] =?UTF-8?q?=EC=9E=90=EB=AC=BC=EC=87=A0=20=EB=88=84?= =?UTF-8?q?=EB=A5=B4=EB=A9=B4=20=EC=BB=AC=EB=9F=BC=20=EA=B0=92=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=95=88=EB=90=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 86 ++++++++++++++----- .../table-list/TableListConfigPanel.tsx | 25 +++++- .../registry/components/table-list/types.ts | 1 + 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 85155eb2..982a7f8c 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -31,6 +31,7 @@ import { Edit, CheckSquare, Trash2, + Lock, } from "lucide-react"; import * as XLSX from "xlsx"; import { FileText, ChevronRightIcon } from "lucide-react"; @@ -391,11 +392,13 @@ export const TableListComponent: React.FC = ({ checkboxCol = { columnName: "__checkbox__", displayName: "", - webType: "checkbox", visible: true, sortable: false, - filterable: false, + searchable: false, width: 40, + align: "center" as const, + order: -1, + editable: false, // 체크박스는 편집 불가 }; } @@ -2068,10 +2071,17 @@ export const TableListComponent: React.FC = ({ // 체크박스 컬럼은 편집 불가 if (columnName === "__checkbox__") return; + // 🆕 편집 불가 컬럼 체크 + const column = visibleColumns.find((col) => col.columnName === columnName); + if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; + } + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); setEditingValue(value !== null && value !== undefined ? String(value) : ""); setFocusedCell({ rowIndex, colIndex }); - }, []); + }, [visibleColumns]); // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) const startEditingRef = useRef<() => void>(() => {}); @@ -3687,6 +3697,11 @@ export const TableListComponent: React.FC = ({ { const col = visibleColumns[colIndex]; if (col && col.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (col.editable === false) { + toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } const row = data[rowIndex]; const mappedCol = joinColumnMapping[col.columnName] || col.columnName; const val = row?.[mappedCol]; @@ -3831,6 +3846,11 @@ export const TableListComponent: React.FC = ({ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { const column = visibleColumns[colIndex]; if (column && column.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (column.editable === false) { + toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } e.preventDefault(); // 편집 시작 (현재 키를 초기값으로) const row = data[rowIndex]; @@ -5186,6 +5206,12 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
+ {/* 🆕 편집 불가 컬럼 표시 */} + {column.editable === false && ( + + + + )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} @@ -5605,6 +5631,8 @@ export const TableListComponent: React.FC = ({ cellValidationError && "bg-red-50 dark:bg-red-950/40 ring-2 ring-red-500 ring-inset", // 🆕 검색 하이라이트 스타일 (노란 배경) isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", + // 🆕 편집 불가 컬럼 스타일 (연한 회색 배경) + column.editable === false && "bg-gray-50 dark:bg-gray-900/30", )} // 🆕 유효성 에러 툴팁 title={cellValidationError || undefined} @@ -5909,25 +5937,39 @@ export const TableListComponent: React.FC = ({
{/* 셀 편집 */} - + {(() => { + const col = visibleColumns[contextMenu.colIndex]; + const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__"; + return ( + + ); + })()} {/* 행 선택/해제 */}
- {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */} + {/* 편집 가능 여부 + 필터 체크박스 */}
+ {/* 🆕 편집 가능 여부 토글 */} + + + {/* 필터 체크박스 */} f.columnName === column.columnName) || false} onCheckedChange={(checked) => { @@ -1174,6 +1194,7 @@ export const TableListConfigPanel: React.FC = ({ } }} className="h-3 w-3" + title="필터에 추가" />
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 2475f58f..a619baa0 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -77,6 +77,7 @@ export interface ColumnConfig { // 새로운 기능들 hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김) autoGeneration?: AutoGenerationConfig; // 자동생성 설정 + editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가) // 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들) additionalJoinInfo?: { From 133b50dcaa5d8c06a49e98495a1855cb8f167033 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 5 Dec 2025 18:29:32 +0900 Subject: [PATCH 12/24] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=20REST=20API=20Request=20Body=20=EC=A0=84=EB=8B=AC=20=EC=98=A4?= =?UTF-8?q?=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 --- .../src/controllers/DashboardController.ts | 24 ++-- .../dashboard/widgets/MapTestWidgetV2.tsx | 130 ++++++++++-------- 2 files changed, 85 insertions(+), 69 deletions(-) diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index e324c332..d0b22db4 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -709,9 +709,9 @@ export class DashboardController { } // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 - const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + const isKmaApi = urlObj.hostname.includes("kma.go.kr"); if (isKmaApi) { - requestConfig.responseType = 'arraybuffer'; + requestConfig.responseType = "arraybuffer"; } const response = await axios(requestConfig); @@ -727,18 +727,22 @@ export class DashboardController { // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) if (isKmaApi && Buffer.isBuffer(data)) { - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); const buffer = Buffer.from(data); - const utf8Text = buffer.toString('utf-8'); - + const utf8Text = buffer.toString("utf-8"); + // UTF-8로 정상 디코딩되었는지 확인 - if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || - (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { - data = { text: utf8Text, contentType, encoding: 'utf-8' }; + if ( + utf8Text.includes("특보") || + utf8Text.includes("경보") || + utf8Text.includes("주의보") || + (utf8Text.includes("#START7777") && !utf8Text.includes("�")) + ) { + data = { text: utf8Text, contentType, encoding: "utf-8" }; } else { // EUC-KR로 디코딩 - const eucKrText = iconv.decode(buffer, 'EUC-KR'); - data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + const eucKrText = iconv.decode(buffer, "EUC-KR"); + data = { text: eucKrText, contentType, encoding: "euc-kr" }; } } // 텍스트 응답인 경우 포맷팅 diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 02cafe2b..94c3a217 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -94,12 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); - + // 이동경로 상태 const [routePoints, setRoutePoints] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); const [routeLoading, setRouteLoading] = useState(false); - const [routeDate, setRouteDate] = useState(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식 + const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { @@ -122,62 +122,59 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }, []); // 이동경로 로드 함수 - const loadRoute = useCallback(async (userId: string, date?: string) => { - if (!userId) { - console.log("🛣️ 이동경로 조회 불가: userId 없음"); - return; - } + const loadRoute = useCallback( + async (userId: string, date?: string) => { + if (!userId) { + return; + } - setRouteLoading(true); - setSelectedUserId(userId); + setRouteLoading(true); + setSelectedUserId(userId); - try { - // 선택한 날짜 기준으로 이동경로 조회 - const targetDate = date || routeDate; - const startOfDay = `${targetDate}T00:00:00.000Z`; - const endOfDay = `${targetDate}T23:59:59.999Z`; - - const query = `SELECT latitude, longitude, recorded_at + try { + // 선택한 날짜 기준으로 이동경로 조회 + const targetDate = date || routeDate; + const startOfDay = `${targetDate}T00:00:00.000Z`; + const endOfDay = `${targetDate}T23:59:59.999Z`; + + const query = `SELECT latitude, longitude, recorded_at FROM vehicle_location_history WHERE user_id = '${userId}' AND recorded_at >= '${startOfDay}' AND recorded_at <= '${endOfDay}' ORDER BY recorded_at ASC`; - console.log("🛣️ 이동경로 쿼리:", query); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, - }, - body: JSON.stringify({ query }), - }); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const points: RoutePoint[] = result.data.rows.map((row: any) => ({ + lat: parseFloat(row.latitude), + lng: parseFloat(row.longitude), + recordedAt: row.recorded_at, + })); - if (response.ok) { - const result = await response.json(); - if (result.success && result.data.rows.length > 0) { - const points: RoutePoint[] = result.data.rows.map((row: any) => ({ - lat: parseFloat(row.latitude), - lng: parseFloat(row.longitude), - recordedAt: row.recorded_at, - })); - - console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`); - setRoutePoints(points); - } else { - console.log("🛣️ 이동경로 데이터 없음"); - setRoutePoints([]); + setRoutePoints(points); + } else { + setRoutePoints([]); + } } + } catch { + setRoutePoints([]); } - } catch (error) { - console.error("이동경로 로드 실패:", error); - setRoutePoints([]); - } - setRouteLoading(false); - }, [routeDate]); + setRouteLoading(false); + }, + [routeDate], + ); // 이동경로 숨기기 const clearRoute = useCallback(() => { @@ -297,6 +294,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }); } + // Request Body 파싱 + let requestBody: any = undefined; + if (source.body) { + try { + requestBody = JSON.parse(source.body); + } catch { + // JSON 파싱 실패시 문자열 그대로 사용 + requestBody = source.body; + } + } + // 백엔드 프록시를 통해 API 호출 const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", @@ -309,6 +317,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { method: source.method || "GET", headers, queryParams, + body: requestBody, + externalConnectionId: source.externalConnectionId, }), }); @@ -344,14 +354,18 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } } + // 데이터가 null/undefined면 빈 결과 반환 + if (data === null || data === undefined) { + return { markers: [], polygons: [] }; + } + const rows = Array.isArray(data) ? data : [data]; // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) - const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); - return finalResult; + return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); }; // Database 데이터 로딩 @@ -485,6 +499,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const polygons: PolygonData[] = []; rows.forEach((row, index) => { + // null/undefined 체크 + if (!row) { + return; + } + // 텍스트 데이터 체크 (기상청 API 등) if (row && typeof row === "object" && row.text && typeof row.text === "string") { const parsedData = parseTextData(row.text); @@ -1098,13 +1117,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }} className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none" /> - - ({routePoints.length}개) - -
@@ -1409,12 +1423,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 트럭 마커 // 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요 const rotation = heading - 90; - + // 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로) // 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함 const normalizedRotation = ((rotation % 360) + 360) % 360; const isFlipped = normalizedRotation > 90 && normalizedRotation < 270; - const transformStyle = isFlipped + const transformStyle = isFlipped ? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)` : `translate(-50%, -50%) rotate(${rotation}deg)`; @@ -1654,9 +1668,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { disabled={routeLoading} className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50" > - {routeLoading && selectedUserId === userId - ? "로딩 중..." - : "🛣️ 이동경로 보기"} + {routeLoading && selectedUserId === userId ? "로딩 중..." : "🛣️ 이동경로 보기"}
); From 30657fd02d38a70166817fbb70805ebce7fb297e Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 10:23:54 +0900 Subject: [PATCH 13/24] =?UTF-8?q?restapi=20=EB=8F=84=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B0=80=EB=8A=A5,=20=EC=B6=9C=EB=B0=9C?= =?UTF-8?q?=EC=A7=80=EB=AA=A9=EC=A0=81=EC=A7=80=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=97=90=20=EA=B0=99=EC=9D=80=EA=B1=B0=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EA=B2=8C,=20=EC=9E=90=EB=AC=BC=EC=87=A0=EA=B1=B8=EB=A9=B4=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=88=98=EC=A0=95=20=EB=AA=BB=ED=95=A8=20?= =?UTF-8?q?tablelistcomponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/table-list-component-guide.mdc | 310 ++++++++++++++++++ .../src/controllers/DashboardController.ts | 9 + .../src/controllers/dynamicFormController.ts | 9 +- .../externalRestApiConnectionService.ts | 11 +- .../src/types/externalRestApiTypes.ts | 3 + .../admin/RestApiConnectionModal.tsx | 14 + .../dashboard/data-sources/MultiApiConfig.tsx | 18 + frontend/components/admin/dashboard/types.ts | 3 + .../dashboard/widgets/MapTestWidgetV2.tsx | 77 ++++- frontend/lib/api/externalRestApiConnection.ts | 3 + .../LocationSwapSelectorComponent.tsx | 42 ++- 11 files changed, 485 insertions(+), 14 deletions(-) create mode 100644 .cursor/rules/table-list-component-guide.mdc diff --git a/.cursor/rules/table-list-component-guide.mdc b/.cursor/rules/table-list-component-guide.mdc new file mode 100644 index 00000000..5d3f0e1f --- /dev/null +++ b/.cursor/rules/table-list-component-guide.mdc @@ -0,0 +1,310 @@ +# TableListComponent 개발 가이드 + +## 개요 + +`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다. + +**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx` + +--- + +## 핵심 기능 목록 + +### 1. 인라인 편집 (Inline Editing) + +- 셀 더블클릭 또는 F2 키로 편집 모드 진입 +- 직접 타이핑으로도 편집 모드 진입 가능 +- Enter로 저장, Escape로 취소 +- **컬럼별 편집 가능 여부 설정** (`editable` 속성) + +```typescript +// ColumnConfig에서 editable 속성 사용 +interface ColumnConfig { + editable?: boolean; // false면 해당 컬럼 인라인 편집 불가 +} +``` + +**편집 불가 컬럼 체크 필수 위치**: +1. `handleCellDoubleClick` - 더블클릭 편집 +2. `onKeyDown` F2 케이스 - 키보드 편집 +3. `onKeyDown` default 케이스 - 직접 타이핑 편집 +4. 컨텍스트 메뉴 "셀 편집" 옵션 + +### 2. 배치 편집 (Batch Editing) + +- 여러 셀 수정 후 일괄 저장/취소 +- `pendingChanges` Map으로 변경사항 추적 +- 저장 전 유효성 검증 + +### 3. 데이터 유효성 검증 (Validation) + +```typescript +type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; +}; +``` + +### 4. 컬럼 헤더 필터 (Header Filter) + +- 각 컬럼 헤더에 필터 아이콘 +- 고유값 목록에서 다중 선택 필터링 +- `headerFilters` Map으로 필터 상태 관리 + +### 5. 필터 빌더 (Filter Builder) + +```typescript +interface FilterCondition { + id: string; + column: string; + operator: "equals" | "notEquals" | "contains" | "notContains" | + "startsWith" | "endsWith" | "greaterThan" | "lessThan" | + "greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty"; + value: string; +} + +interface FilterGroup { + id: string; + logic: "AND" | "OR"; + conditions: FilterCondition[]; +} +``` + +### 6. 검색 패널 (Search Panel) + +- 전체 데이터 검색 +- 검색어 하이라이팅 +- `searchHighlights` Map으로 하이라이트 위치 관리 + +### 7. 엑셀 내보내기 (Excel Export) + +- `xlsx` 라이브러리 사용 +- 현재 표시 데이터 또는 전체 데이터 내보내기 + +```typescript +import * as XLSX from "xlsx"; + +// 사용 예시 +const worksheet = XLSX.utils.json_to_sheet(exportData); +const workbook = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1"); +XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`); +``` + +### 8. 클립보드 복사 (Copy to Clipboard) + +- 선택된 행 또는 전체 데이터 복사 +- 탭 구분자로 엑셀 붙여넣기 호환 + +### 9. 컨텍스트 메뉴 (Context Menu) + +- 우클릭으로 메뉴 표시 +- 셀 편집, 행 복사, 행 삭제 등 옵션 +- 편집 불가 컬럼은 "(잠김)" 표시 + +### 10. 키보드 네비게이션 + +| 키 | 동작 | +|---|---| +| Arrow Keys | 셀 이동 | +| Tab | 다음 셀 | +| Shift+Tab | 이전 셀 | +| F2 | 편집 모드 | +| Enter | 저장 후 아래로 이동 | +| Escape | 편집 취소 | +| Ctrl+C | 복사 | +| Delete | 셀 값 삭제 | + +### 11. 컬럼 리사이징 + +- 컬럼 헤더 경계 드래그로 너비 조절 +- `columnWidths` 상태로 관리 +- localStorage에 저장 + +### 12. 컬럼 순서 변경 + +- 드래그 앤 드롭으로 컬럼 순서 변경 +- `columnOrder` 상태로 관리 +- localStorage에 저장 + +### 13. 상태 영속성 (State Persistence) + +```typescript +// localStorage 키 패턴 +const stateKey = `tableState_${tableName}_${userId}`; + +// 저장되는 상태 +interface TableState { + columnWidths: Record; + columnOrder: string[]; + sortBy: string; + sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +### 14. 그룹화 및 그룹 소계 + +```typescript +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; + summary?: Record; +} +``` + +### 15. 총계 요약 (Total Summary) + +- 숫자 컬럼의 합계, 평균, 개수 표시 +- 테이블 하단에 요약 행 렌더링 + +--- + +## 캐싱 전략 + +```typescript +// 테이블 컬럼 캐시 +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 + +// API 호출 디바운싱 +const debouncedApiCall = ( + key: string, + fn: (...args: T) => Promise, + delay: number = 300 +) => { ... }; +``` + +--- + +## 필수 Import + +```typescript +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { TableListConfig, ColumnConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; +import * as XLSX from "xlsx"; +import { toast } from "sonner"; +``` + +--- + +## 주요 상태 (State) + +```typescript +// 데이터 관련 +const [tableData, setTableData] = useState([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// 편집 관련 +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; +} | null>(null); +const [editingValue, setEditingValue] = useState(""); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// 필터 관련 +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); +const [searchHighlights, setSearchHighlights] = useState>(new Map()); + +// 컬럼 관련 +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); +const [frozenColumns, setFrozenColumns] = useState([]); + +// 선택 관련 +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + +// 정렬 관련 +const [sortBy, setSortBy] = useState(""); +const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + +// 페이지네이션 +const [currentPage, setCurrentPage] = useState(1); +const [pageSize, setPageSize] = useState(20); +const [totalCount, setTotalCount] = useState(0); +``` + +--- + +## 편집 불가 컬럼 구현 체크리스트 + +새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요: + +- [ ] `column.editable === false` 체크 추가 +- [ ] 편집 불가 시 `toast.warning()` 메시지 표시 +- [ ] `return` 또는 `break`로 편집 모드 진입 방지 + +```typescript +// 표준 편집 불가 체크 패턴 +const column = visibleColumns.find((col) => col.columnName === columnName); +if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; +} +``` + +--- + +## 시각적 표시 + +### 편집 불가 컬럼 표시 + +```tsx +// 헤더에 잠금 아이콘 +{column.editable === false && ( + +)} + +// 셀 배경색 +className={cn( + column.editable === false && "bg-gray-50 dark:bg-gray-900/30" +)} +``` + +--- + +## 성능 최적화 + +1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값 +2. **useCallback 사용**: 이벤트 핸들러 함수들 +3. **디바운싱**: API 호출, 검색, 필터링 +4. **캐싱**: 테이블 컬럼 정보, 코드 데이터 + +--- + +## 주의사항 + +1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함 +2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인 +3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성 +4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리) + +--- + +## 관련 파일 + +- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의 +- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널 +- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달 +- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블 diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d0b22db4..a03478b9 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -632,6 +632,9 @@ export class DashboardController { validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) }; + // 연결 정보 (응답에 포함용) + let connectionInfo: { saveToHistory?: boolean } | null = null; + // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 if (externalConnectionId) { try { @@ -652,6 +655,11 @@ export class DashboardController { if (connectionResult.success && connectionResult.data) { const connection = connectionResult.data; + // 연결 정보 저장 (응답에 포함) + connectionInfo = { + saveToHistory: connection.save_to_history === "Y", + }; + // 인증 헤더 생성 (DB 토큰 등) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( @@ -753,6 +761,7 @@ export class DashboardController { res.status(200).json({ success: true, data, + connectionInfo, // 외부 연결 정보 (saveToHistory 등) }); } catch (error: any) { const status = error.response?.status || 500; diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 30364189..97cd2cc1 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -492,7 +492,7 @@ export const saveLocationHistory = async ( res: Response ): Promise => { try { - const { companyCode, userId } = req.user as any; + const { companyCode, userId: loginUserId } = req.user as any; const { latitude, longitude, @@ -508,10 +508,17 @@ export const saveLocationHistory = async ( destinationName, recordedAt, vehicleId, + userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등) } = req.body; + // 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등) + // 없으면 로그인한 사용자의 userId 사용 + const userId = requestUserId || loginUserId; + console.log("📍 [saveLocationHistory] 요청:", { userId, + requestUserId, + loginUserId, companyCode, latitude, longitude, diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 2632a6e6..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService { connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, - company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + company_code, is_active, created_by, save_to_history + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; @@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService { data.company_code || "*", data.is_active || "Y", data.created_by || "system", + data.save_to_history || "N", ]; // 디버깅: 저장하려는 데이터 로깅 @@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService { paramIndex++; } + if (data.save_to_history !== undefined) { + updateFields.push(`save_to_history = $${paramIndex}`); + params.push(data.save_to_history); + paramIndex++; + } + if (data.updated_by !== undefined) { updateFields.push(`updated_by = $${paramIndex}`); params.push(data.updated_by); diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 8d95a4a6..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -53,6 +53,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // 위치 이력 저장 설정 (지도 위젯용) + save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 95ac6e76..0a9cecd0 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: const [retryCount, setRetryCount] = useState(0); const [retryDelay, setRetryDelay] = useState(1000); const [isActive, setIsActive] = useState(true); + const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정 // UI 상태 const [showAdvanced, setShowAdvanced] = useState(false); @@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setRetryCount(connection.retry_count || 0); setRetryDelay(connection.retry_delay || 1000); setIsActive(connection.is_active === "Y"); + setSaveToHistory(connection.save_to_history === "Y"); // 테스트 초기값 설정 setTestEndpoint(""); @@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setRetryCount(0); setRetryDelay(1000); setIsActive(true); + setSaveToHistory(false); // 테스트 초기값 설정 setTestEndpoint(""); @@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: retry_delay: retryDelay, // company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정 is_active: isActive ? "Y" : "N", + save_to_history: saveToHistory ? "Y" : "N", }; console.log("저장하려는 데이터:", { @@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: 활성 상태
+ +
+ + + + (지도 위젯에서 이 API 데이터를 vehicle_location_history에 저장) + +
{/* 헤더 관리 */} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 86da8fe7..f92e440a 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
)} + {/* 위치 이력 저장 설정 (지도 위젯용) */} +
+
+ +

+ REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장합니다 +

+
+ onChange({ saveToHistory: checked })} + /> +
+ {/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */} {testResult?.success && availableColumns.length > 0 && (
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 19599b69..bc52ecb8 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -183,6 +183,9 @@ export interface ChartDataSource { label: string; // 표시할 한글명 (예: 차량 번호) format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷 }[]; + + // REST API 위치 데이터 저장 설정 (MapTestWidgetV2용) + saveToHistory?: boolean; // REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 } export interface ChartConfig { diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 94c3a217..9b0db43a 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -365,7 +365,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) - return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + + // ✅ REST API 데이터를 vehicle_location_history에 자동 저장 (경로 보기용) + // - 모든 REST API 차량 위치 데이터는 자동으로 저장됨 + if (mapData.markers.length > 0) { + try { + const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""; + + // 마커 데이터를 vehicle_location_history에 저장 + for (const marker of mapData.markers) { + // user_id 추출 (마커 description에서 파싱) + let userId = ""; + let vehicleId: number | undefined = undefined; + let vehicleName = ""; + + if (marker.description) { + try { + const parsed = JSON.parse(marker.description); + // 다양한 필드명 지원 (plate_no 우선 - 차량 번호판으로 경로 구분) + userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || + parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || + parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || + parsed.id || parsed.code || ""; + vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId; + vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName || + parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || ""; + } catch { + // 파싱 실패 시 무시 + } + } + + // user_id가 없으면 마커 이름이나 ID를 사용 + if (!userId) { + userId = marker.name || marker.id || `marker_${Date.now()}`; + } + + // vehicle_location_history에 저장 + await fetch(getApiUrl("/api/dynamic-form/location-history"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + credentials: "include", + body: JSON.stringify({ + latitude: marker.lat, + longitude: marker.lng, + userId: userId, + vehicleId: vehicleId, + tripStatus: "api_tracking", // REST API에서 가져온 데이터 표시 + departureName: source.name || "REST API", + destinationName: vehicleName || marker.name, + }), + }); + + console.log("📍 [saveToHistory] 저장 완료:", { userId, lat: marker.lat, lng: marker.lng }); + } + } catch (saveError) { + console.error("❌ [saveToHistory] 저장 실패:", saveError); + // 저장 실패해도 마커 표시는 계속 + } + } + + return mapData; }; // Database 데이터 로딩 @@ -1659,16 +1722,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {(() => { try { const parsed = JSON.parse(marker.description || "{}"); - const userId = parsed.user_id; - if (userId) { + // 다양한 필드명 지원 (plate_no 우선) + const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || + parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || + parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || + parsed.id || parsed.code || marker.name; + if (visibleUserId) { return (
); diff --git a/frontend/lib/api/externalRestApiConnection.ts b/frontend/lib/api/externalRestApiConnection.ts index f907ee85..d58545f6 100644 --- a/frontend/lib/api/externalRestApiConnection.ts +++ b/frontend/lib/api/externalRestApiConnection.ts @@ -45,6 +45,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // 위치 이력 저장 설정 (지도 위젯용) + save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 5dc4a165..3f1a723b 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -343,8 +343,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -387,8 +392,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} @@ -419,8 +429,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -451,8 +466,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} @@ -479,8 +499,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -508,8 +533,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} From 76bad47bc7b73d503c96b31000d500e326b48499 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Dec 2025 15:50:58 +0900 Subject: [PATCH 14/24] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EB=AA=A8=EB=8B=AC=EC=97=B4=EA=B8=B0=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EC=BB=AC=EB=9F=BC=20=EB=A7=A4=ED=95=91?= =?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 --- frontend/components/screen/ScreenDesigner.tsx | 17 ++ .../config-panels/ButtonConfigPanel.tsx | 278 ++++++++++++++++++ .../screen/panels/PropertiesPanel.tsx | 15 +- frontend/lib/utils/buttonActions.ts | 22 +- 4 files changed, 324 insertions(+), 8 deletions(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 2118bca3..3a440f07 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2239,10 +2239,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, }); + // 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스) + // 예: "창고코드" → "warehouse_code" 또는 그대로 유지 + const generateDefaultColumnName = (label: string): string => { + // 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능) + // 영문의 경우 스네이크 케이스로 변환 + if (/[가-힣]/.test(label)) { + // 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환 + return label.replace(/\s+/g, "_").toLowerCase(); + } + // 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환 + return label + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/\s+/g, "_") + .toLowerCase(); + }; + const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 label: component.name, + columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성 widgetType: component.webType, componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) position: snappedPosition, diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 36f420fd..39f32a73 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -91,6 +91,14 @@ export const ButtonConfigPanel: React.FC = ({ const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + // 🆕 openModalWithData 전용 필드 매핑 상태 + const [modalSourceColumns, setModalSourceColumns] = useState>([]); + const [modalTargetColumns, setModalTargetColumns] = useState>([]); + const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState>({}); + const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState>({}); + const [modalSourceSearch, setModalSourceSearch] = useState>({}); + const [modalTargetSearch, setModalTargetSearch] = useState>({}); + // 🎯 플로우 위젯이 화면에 있는지 확인 const hasFlowWidget = useMemo(() => { const found = allComponents.some((comp: any) => { @@ -318,6 +326,88 @@ export const ButtonConfigPanel: React.FC = ({ loadColumns(); }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + // 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드 + useEffect(() => { + const actionType = config.action?.type; + if (actionType !== "openModalWithData") return; + + const loadModalMappingColumns = async () => { + // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 + // allComponents에서 split-panel-layout 또는 table-list 찾기 + let sourceTableName: string | null = null; + + for (const comp of allComponents) { + const compType = comp.componentType || (comp as any).componentConfig?.type; + if (compType === "split-panel-layout" || compType === "screen-split-panel") { + // 분할 패널의 좌측 테이블명 + sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName || + (comp as any).componentConfig?.leftTableName; + break; + } + if (compType === "table-list") { + sourceTableName = (comp as any).componentConfig?.tableName; + break; + } + } + + // 소스 테이블 컬럼 로드 + if (sourceTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setModalSourceColumns(columns); + console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length); + } + } + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + } + } + + // 타겟 화면의 테이블 컬럼 로드 + const targetScreenId = config.action?.targetScreenId; + if (targetScreenId) { + try { + // 타겟 화면 정보 가져오기 + const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); + if (screenResponse.data.success && screenResponse.data.data) { + const targetTableName = screenResponse.data.data.tableName; + if (targetTableName) { + const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); + if (columnResponse.data.success) { + let columnData = columnResponse.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setModalTargetColumns(columns); + console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length); + } + } + } + } + } catch (error) { + console.error("타겟 화면 테이블 컬럼 로드 실패:", error); + } + } + }; + + loadModalMappingColumns(); + }, [config.action?.type, config.action?.targetScreenId, allComponents]); + // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { const fetchScreens = async () => { @@ -1024,6 +1114,194 @@ export const ButtonConfigPanel: React.FC = ({ SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요

+ + {/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */} +
+
+ + +
+

+ 소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요. +
+ 예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑) +

+ + {/* 컬럼 로드 상태 표시 */} + {modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? ( +
+ 소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개 +
+ ) : ( +
+ 분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다. +
+ )} + + {(config.action?.fieldMappings || []).length === 0 ? ( +
+

+ 매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다. +

+
+ ) : ( +
+ {(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( +
+ {/* 소스 필드 선택 (Combobox) */} +
+ setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setModalSourceSearch((prev) => ({ ...prev, [index]: value }))} + /> + + 컬럼을 찾을 수 없습니다 + + {modalSourceColumns.map((col) => ( + { + const mappings = [...(config.action?.fieldMappings || [])]; + mappings[index] = { ...mappings[index], sourceField: col.name }; + onUpdateProperty("componentConfig.action.fieldMappings", mappings); + setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + + + {/* 타겟 필드 선택 (Combobox) */} +
+ setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setModalTargetSearch((prev) => ({ ...prev, [index]: value }))} + /> + + 컬럼을 찾을 수 없습니다 + + {modalTargetColumns.map((col) => ( + { + const mappings = [...(config.action?.fieldMappings || [])]; + mappings[index] = { ...mappings[index], targetField: col.name }; + onUpdateProperty("componentConfig.action.fieldMappings", mappings); + setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )} +
)} diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index ff21ac3e..bb663c74 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -584,20 +584,23 @@ const PropertiesPanelComponent: React.FC = ({
- {selectedComponent.type === "widget" && ( + {(selectedComponent.type === "widget" || selectedComponent.type === "component") && ( <>
onUpdateProperty("columnName", e.target.value)} + placeholder="formData에서 사용할 필드명" + className="h-8" + title="분할 패널에서 데이터를 전달받을 때 사용되는 필드명입니다" /> +

+ 분할 패널에서 데이터를 전달받을 때 매핑되는 필드명 +

diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 7c1545d8..275efbb5 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -59,6 +59,7 @@ export interface ButtonActionConfig { popupWidth?: number; popupHeight?: number; dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용) + fieldMappings?: Array<{ sourceField: string; targetField: string }>; // 🆕 필드 매핑 (openModalWithData용) // 확인 메시지 confirmMessage?: string; @@ -1548,10 +1549,27 @@ export class ButtonActionExecutor { } // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) - const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; + const rawParentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; + + // 🆕 필드 매핑 적용 (소스 컬럼 → 타겟 컬럼) + let parentData = { ...rawParentData }; + if (config.fieldMappings && Array.isArray(config.fieldMappings) && config.fieldMappings.length > 0) { + console.log("🔄 [openModalWithData] 필드 매핑 적용:", config.fieldMappings); + + config.fieldMappings.forEach((mapping: { sourceField: string; targetField: string }) => { + if (mapping.sourceField && mapping.targetField && rawParentData[mapping.sourceField] !== undefined) { + // 타겟 필드에 소스 필드 값 복사 + parentData[mapping.targetField] = rawParentData[mapping.sourceField]; + console.log(` ✅ ${mapping.sourceField} → ${mapping.targetField}: ${rawParentData[mapping.sourceField]}`); + } + }); + } + console.log("📦 [openModalWithData] 부모 데이터 전달:", { dataSourceId, - parentData, + rawParentData, + mappedParentData: parentData, + fieldMappings: config.fieldMappings, }); // 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함) From ae7c47ee5f8d902a1d894c6a80044831eab9519e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Dec 2025 17:13:14 +0900 Subject: [PATCH 15/24] =?UTF-8?q?=EC=B0=BD=EA=B3=A0=20=EB=A0=89=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=93=B1=EB=A1=9D=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80?= =?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 --- .../tableCategoryValueController.ts | 47 ++ .../src/routes/tableCategoryValueRoutes.ts | 4 + backend-node/src/services/dataService.ts | 44 +- .../src/services/tableCategoryValueService.ts | 64 ++ .../screen/InteractiveScreenViewer.tsx | 33 + frontend/lib/api/tableCategoryValue.ts | 23 + .../rack-structure/RackStructureComponent.tsx | 384 ++++++++++-- .../rack-structure/RackStructureRenderer.tsx | 30 +- .../components/rack-structure/types.ts | 21 +- frontend/lib/utils/buttonActions.ts | 570 +++++++++++++----- 10 files changed, 1027 insertions(+), 193 deletions(-) diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 248bb867..75e225e6 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re } }; +/** + * 카테고리 코드로 라벨 조회 + * + * POST /api/table-categories/labels-by-codes + * + * Body: + * - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"]) + * + * Response: + * - { [code]: label } 형태의 매핑 객체 + */ +export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { valueCodes } = req.body; + + if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) { + return res.json({ + success: true, + data: {}, + }); + } + + logger.info("카테고리 코드로 라벨 조회", { + valueCodes, + companyCode, + }); + + const labels = await tableCategoryValueService.getCategoryLabelsByCodes( + valueCodes, + companyCode + ); + + return res.json({ + success: true, + data: labels, + }); + } catch (error: any) { + logger.error(`카테고리 라벨 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 라벨 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 2레벨 메뉴 목록 조회 * diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index b79aab75..e59d9b9d 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -13,6 +13,7 @@ import { deleteColumnMapping, deleteColumnMappingsByColumn, getSecondLevelMenus, + getCategoryLabelsByCodes, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues); // 카테고리 값 순서 변경 router.post("/values/reorder", reorderCategoryValues); +// 카테고리 코드로 라벨 조회 +router.post("/labels-by-codes", getCategoryLabelsByCodes); + // ================================================ // 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명) // ================================================ diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index fd85248d..a278eb97 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -907,8 +907,27 @@ class DataService { return validation.error!; } - const columns = Object.keys(data); - const values = Object.values(data); + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) + const tableColumns = await this.getTableColumnsSimple(tableName); + const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name)); + + const invalidColumns: string[] = []; + const filteredData = Object.fromEntries( + Object.entries(data).filter(([key]) => { + if (validColumnNames.has(key)) { + return true; + } + invalidColumns.push(key); + return false; + }) + ); + + if (invalidColumns.length > 0) { + console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`); + } + + const columns = Object.keys(filteredData); + const values = Object.values(filteredData); const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", "); @@ -951,9 +970,28 @@ class DataService { // _relationInfo 추출 (조인 관계 업데이트용) const relationInfo = data._relationInfo; - const cleanData = { ...data }; + let cleanData = { ...data }; delete cleanData._relationInfo; + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) + const tableColumns = await this.getTableColumnsSimple(tableName); + const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name)); + + const invalidColumns: string[] = []; + cleanData = Object.fromEntries( + Object.entries(cleanData).filter(([key]) => { + if (validColumnNames.has(key)) { + return true; + } + invalidColumns.push(key); + return false; + }) + ); + + if (invalidColumns.length > 0) { + console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`); + } + // Primary Key 컬럼 찾기 const pkResult = await query<{ attname: string }>( `SELECT a.attname diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index b68d5f05..cdf1b838 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1258,6 +1258,70 @@ class TableCategoryValueService { throw error; } } + + /** + * 카테고리 코드로 라벨 조회 + * + * @param valueCodes - 카테고리 코드 배열 + * @param companyCode - 회사 코드 + * @returns { [code]: label } 형태의 매핑 객체 + */ + async getCategoryLabelsByCodes( + valueCodes: string[], + companyCode: string + ): Promise> { + try { + if (!valueCodes || valueCodes.length === 0) { + return {}; + } + + logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode }); + + const pool = getPool(); + + // 동적으로 파라미터 플레이스홀더 생성 + const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders}) + AND is_active = true + `; + params = valueCodes; + } else { + // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders}) + AND is_active = true + AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + `; + params = [...valueCodes, companyCode]; + } + + const result = await pool.query(query, params); + + // { [code]: label } 형태로 변환 + const labels: Record = {}; + for (const row of result.rows) { + labels[row.value_code] = row.value_label; + } + + logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); + + return labels; + } catch (error: any) { + logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error }); + throw error; + } + } } export default new TableCategoryValueService(); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index d9186999..223490e6 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -441,6 +441,39 @@ export const InteractiveScreenViewer: React.FC = ( ); } + // 🆕 렉 구조 컴포넌트 처리 + if (comp.type === "component" && componentType === "rack-structure") { + const { RackStructureComponent } = require("@/lib/registry/components/rack-structure/RackStructureComponent"); + const componentConfig = (comp as any).componentConfig || {}; + // config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접 + const rackConfig = componentConfig.config || componentConfig; + + console.log("🏗️ 렉 구조 컴포넌트 렌더링:", { + componentType, + componentConfig, + rackConfig, + fieldMapping: rackConfig.fieldMapping, + formData, + }); + + return ( +
+ { + console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개"); + // 컴포넌트의 columnName을 키로 사용 + const fieldKey = (comp as any).columnName || "_rackStructureLocations"; + updateFormData(fieldKey, locations); + }} + isPreview={false} + /> +
+ ); + } + const { widgetType, label, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; diff --git a/frontend/lib/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts index 3c5380d1..253e66d0 100644 --- a/frontend/lib/api/tableCategoryValue.ts +++ b/frontend/lib/api/tableCategoryValue.ts @@ -167,6 +167,29 @@ export async function reorderCategoryValues(orderedValueIds: number[]) { } } +/** + * 카테고리 코드로 라벨 조회 + * + * @param valueCodes - 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"]) + * @returns { [code]: label } 형태의 매핑 객체 + */ +export async function getCategoryLabelsByCodes(valueCodes: string[]) { + try { + if (!valueCodes || valueCodes.length === 0) { + return { success: true, data: {} }; + } + + const response = await apiClient.post<{ + success: boolean; + data: Record; + }>("/table-categories/labels-by-codes", { valueCodes }); + return response.data; + } catch (error: any) { + console.error("카테고리 라벨 조회 실패:", error); + return { success: false, error: error.message, data: {} }; + } +} + // ================================================ // 컬럼 매핑 관련 API (논리명 ↔ 물리명) // ================================================ diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index f49e4462..7ddd6326 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -23,6 +23,8 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { cn } from "@/lib/utils"; +import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; +import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { RackStructureComponentProps, RackLineCondition, @@ -31,6 +33,13 @@ import { RackStructureContext, } from "./types"; +// 기존 위치 데이터 타입 +interface ExistingLocation { + row_num: string; + level_num: string; + location_code: string; +} + // 고유 ID 생성 const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -185,6 +194,7 @@ export const RackStructureComponent: React.FC = ({ onChange, onConditionsChange, isPreview = false, + tableName, }) => { // 조건 목록 const [conditions, setConditions] = useState( @@ -200,6 +210,11 @@ export const RackStructureComponent: React.FC = ({ // 미리보기 데이터 const [previewData, setPreviewData] = useState([]); const [isPreviewGenerated, setIsPreviewGenerated] = useState(false); + + // 기존 데이터 중복 체크 관련 상태 + const [existingLocations, setExistingLocations] = useState([]); + const [isCheckingDuplicates, setIsCheckingDuplicates] = useState(false); + const [duplicateErrors, setDuplicateErrors] = useState<{ row: number; existingLevels: number[] }[]>([]); // 설정값 const maxConditions = config.maxConditions || 10; @@ -208,6 +223,60 @@ export const RackStructureComponent: React.FC = ({ const readonly = config.readonly || isPreview; const fieldMapping = config.fieldMapping || {}; + // 카테고리 라벨 캐시 상태 + const [categoryLabels, setCategoryLabels] = useState>({}); + + // 카테고리 코드인지 확인 + const isCategoryCode = (value: string | undefined): boolean => { + return typeof value === "string" && value.startsWith("CATEGORY_"); + }; + + // 카테고리 라벨 조회 (비동기) + useEffect(() => { + const loadCategoryLabels = async () => { + if (!formData) return; + + // 카테고리 코드인 값들만 수집 + const valuesToLookup: string[] = []; + const fieldsToCheck = [ + fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined, + fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined, + fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined, + fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined, + ]; + + for (const value of fieldsToCheck) { + if (value && isCategoryCode(value) && !categoryLabels[value]) { + valuesToLookup.push(value); + } + } + + if (valuesToLookup.length === 0) return; + + try { + // 카테고리 코드로 라벨 일괄 조회 + const response = await getCategoryLabelsByCodes(valuesToLookup); + if (response.success && response.data) { + console.log("✅ 카테고리 라벨 조회 완료:", response.data); + setCategoryLabels((prev) => ({ ...prev, ...response.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + loadCategoryLabels(); + }, [formData, fieldMapping]); + + // 카테고리 코드를 라벨로 변환하는 헬퍼 함수 + const getCategoryLabel = useCallback((value: string | undefined): string | undefined => { + if (!value) return undefined; + if (isCategoryCode(value)) { + return categoryLabels[value] || value; + } + return value; + }, [categoryLabels]); + // 필드 매핑을 통해 formData에서 컨텍스트 추출 const context: RackStructureContext = useMemo(() => { // propContext가 있으면 우선 사용 @@ -216,27 +285,33 @@ export const RackStructureComponent: React.FC = ({ // formData와 fieldMapping을 사용하여 컨텍스트 생성 if (!formData) return {}; - return { + const rawFloor = fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined; + const rawZone = fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined; + const rawLocationType = fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined; + const rawStatus = fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined; + + const ctx = { warehouseCode: fieldMapping.warehouseCodeField ? formData[fieldMapping.warehouseCodeField] : undefined, warehouseName: fieldMapping.warehouseNameField ? formData[fieldMapping.warehouseNameField] : undefined, - floor: fieldMapping.floorField - ? formData[fieldMapping.floorField]?.toString() - : undefined, - zone: fieldMapping.zoneField - ? formData[fieldMapping.zoneField] - : undefined, - locationType: fieldMapping.locationTypeField - ? formData[fieldMapping.locationTypeField] - : undefined, - status: fieldMapping.statusField - ? formData[fieldMapping.statusField] - : undefined, + // 카테고리 값은 라벨로 변환 + floor: getCategoryLabel(rawFloor?.toString()), + zone: getCategoryLabel(rawZone), + locationType: getCategoryLabel(rawLocationType), + status: getCategoryLabel(rawStatus), }; - }, [propContext, formData, fieldMapping]); + + console.log("🏗️ [RackStructure] context 생성:", { + fieldMapping, + rawValues: { rawFloor, rawZone, rawLocationType, rawStatus }, + context: ctx, + }); + + return ctx; + }, [propContext, formData, fieldMapping, getCategoryLabel]); // 필수 필드 검증 const missingFields = useMemo(() => { @@ -283,6 +358,154 @@ export const RackStructureComponent: React.FC = ({ setConditions((prev) => prev.filter((cond) => cond.id !== id)); }, []); + // 열 범위 중복 검사 + const rowOverlapErrors = useMemo(() => { + const errors: { conditionIndex: number; overlappingWith: number; overlappingRows: number[] }[] = []; + + for (let i = 0; i < conditions.length; i++) { + const cond1 = conditions[i]; + if (cond1.startRow <= 0 || cond1.endRow < cond1.startRow) continue; + + for (let j = i + 1; j < conditions.length; j++) { + const cond2 = conditions[j]; + if (cond2.startRow <= 0 || cond2.endRow < cond2.startRow) continue; + + // 범위 겹침 확인 + const overlapStart = Math.max(cond1.startRow, cond2.startRow); + const overlapEnd = Math.min(cond1.endRow, cond2.endRow); + + if (overlapStart <= overlapEnd) { + // 겹치는 열 목록 + const overlappingRows: number[] = []; + for (let r = overlapStart; r <= overlapEnd; r++) { + overlappingRows.push(r); + } + + errors.push({ + conditionIndex: i, + overlappingWith: j, + overlappingRows, + }); + } + } + } + + return errors; + }, [conditions]); + + // 중복 열이 있는지 확인 + const hasRowOverlap = rowOverlapErrors.length > 0; + + // 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지) + const warehouseCodeForQuery = context.warehouseCode; + const floorForQuery = context.floor; + const zoneForQuery = context.zone; + + // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) + useEffect(() => { + const loadExistingLocations = async () => { + console.log("🏗️ [RackStructure] 기존 데이터 조회 체크:", { + warehouseCode: warehouseCodeForQuery, + floor: floorForQuery, + zone: zoneForQuery, + }); + + // 필수 조건이 충족되지 않으면 기존 데이터 초기화 + if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵"); + setExistingLocations([]); + setDuplicateErrors([]); + return; + } + + setIsCheckingDuplicates(true); + try { + // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 + const filterParams = { + warehouse_id: warehouseCodeForQuery, + floor: floorForQuery, + zone: zoneForQuery, + }; + console.log("🔍 기존 위치 데이터 조회 시작:", filterParams); + + const response = await DynamicFormApi.getTableData("warehouse_location", { + filters: filterParams, + page: 1, + pageSize: 1000, // 충분히 큰 값 + }); + + console.log("🔍 기존 위치 데이터 응답:", response); + + // API 응답 구조: { success: true, data: [...] } 또는 { success: true, data: { data: [...] } } + const dataArray = Array.isArray(response.data) + ? response.data + : (response.data?.data || []); + + if (response.success && dataArray.length > 0) { + const existing = dataArray.map((item: any) => ({ + row_num: item.row_num, + level_num: item.level_num, + location_code: item.location_code, + })); + setExistingLocations(existing); + console.log("✅ 기존 위치 데이터 조회 완료:", existing.length, "개", existing); + } else { + console.log("⚠️ 기존 위치 데이터 없음 또는 조회 실패"); + setExistingLocations([]); + } + } catch (error) { + console.error("기존 위치 데이터 조회 실패:", error); + setExistingLocations([]); + } finally { + setIsCheckingDuplicates(false); + } + }; + + loadExistingLocations(); + }, [warehouseCodeForQuery, floorForQuery, zoneForQuery]); + + // 조건 변경 시 기존 데이터와 중복 체크 + useEffect(() => { + if (existingLocations.length === 0) { + setDuplicateErrors([]); + return; + } + + // 현재 조건에서 생성될 열 목록 + const plannedRows = new Map(); // row -> levels + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + for (let row = cond.startRow; row <= cond.endRow; row++) { + const levels: number[] = []; + for (let level = 1; level <= cond.levels; level++) { + levels.push(level); + } + plannedRows.set(row, levels); + } + } + }); + + // 기존 데이터와 중복 체크 + const errors: { row: number; existingLevels: number[] }[] = []; + plannedRows.forEach((levels, row) => { + const existingForRow = existingLocations.filter( + (loc) => parseInt(loc.row_num) === row + ); + if (existingForRow.length > 0) { + const existingLevels = existingForRow.map((loc) => parseInt(loc.level_num)); + const duplicateLevels = levels.filter((l) => existingLevels.includes(l)); + if (duplicateLevels.length > 0) { + errors.push({ row, existingLevels: duplicateLevels }); + } + } + }); + + setDuplicateErrors(errors); + }, [conditions, existingLocations]); + + // 기존 데이터와 중복이 있는지 확인 + const hasDuplicateWithExisting = duplicateErrors.length > 0; + // 통계 계산 const statistics = useMemo(() => { let totalLocations = 0; @@ -312,11 +535,12 @@ export const RackStructureComponent: React.FC = ({ const floor = context?.floor || "1"; const zone = context?.zone || "A"; - // 코드 생성 (예: WH001-1A-01-1) + // 코드 생성 (예: WH001-1층D구역-01-1) const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; - // 이름 생성 (예: A구역-01열-1단) - const name = `${zone}구역-${row.toString().padStart(2, "0")}열-${level}단`; + // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 + const zoneName = zone.includes("구역") ? zone : `${zone}구역`; + const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; return { code, name }; }, @@ -325,12 +549,39 @@ export const RackStructureComponent: React.FC = ({ // 미리보기 생성 const generatePreview = useCallback(() => { + console.log("🔍 [generatePreview] 검증 시작:", { + missingFields, + hasRowOverlap, + hasDuplicateWithExisting, + duplicateErrorsCount: duplicateErrors.length, + existingLocationsCount: existingLocations.length, + }); + // 필수 필드 검증 if (missingFields.length > 0) { alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); return; } + // 열 범위 중복 검증 + if (hasRowOverlap) { + const overlapInfo = rowOverlapErrors.map((err) => { + const rows = err.overlappingRows.join(", "); + return `조건 ${err.conditionIndex + 1}과 조건 ${err.overlappingWith + 1}의 ${rows}열`; + }).join("\n"); + alert(`열 범위가 중복됩니다:\n${overlapInfo}\n\n중복된 열을 수정해주세요.`); + return; + } + + // 기존 데이터와 중복 검증 - duplicateErrors 직접 체크 + if (duplicateErrors.length > 0) { + const duplicateInfo = duplicateErrors.map((err) => { + return `${err.row}열 ${err.existingLevels.join(", ")}단`; + }).join(", "); + alert(`이미 등록된 위치가 있습니다:\n${duplicateInfo}\n\n해당 열/단을 제외하고 등록하거나, 기존 데이터를 삭제해주세요.`); + return; + } + const locations: GeneratedLocation[] = []; conditions.forEach((cond) => { @@ -338,15 +589,17 @@ export const RackStructureComponent: React.FC = ({ for (let row = cond.startRow; row <= cond.endRow; row++) { for (let level = 1; level <= cond.levels; level++) { const { code, name } = generateLocationCode(row, level); + // 테이블 컬럼명과 동일하게 생성 locations.push({ - rowNum: row, - levelNum: level, - locationCode: code, - locationName: name, - locationType: context?.locationType || "선반", + row_num: String(row), + level_num: String(level), + location_code: code, + location_name: name, + location_type: context?.locationType || "선반", status: context?.status || "사용", - // 추가 필드 - warehouseCode: context?.warehouseCode, + // 추가 필드 (테이블 컬럼명과 동일) + warehouse_id: context?.warehouseCode, + warehouse_name: context?.warehouseName, floor: context?.floor, zone: context?.zone, }); @@ -357,14 +610,14 @@ export const RackStructureComponent: React.FC = ({ // 정렬: 열 -> 단 순서 locations.sort((a, b) => { - if (a.rowNum !== b.rowNum) return a.rowNum - b.rowNum; - return a.levelNum - b.levelNum; + if (a.row_num !== b.row_num) return parseInt(a.row_num) - parseInt(b.row_num); + return parseInt(a.level_num) - parseInt(b.level_num); }); setPreviewData(locations); setIsPreviewGenerated(true); onChange?.(locations); - }, [conditions, context, generateLocationCode, onChange, missingFields]); + }, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]); // 템플릿 저장 const saveTemplate = useCallback(() => { @@ -448,6 +701,66 @@ export const RackStructureComponent: React.FC = ({ )} + {/* 열 범위 중복 경고 */} + {hasRowOverlap && ( + + + + 열 범위가 중복됩니다! +
    + {rowOverlapErrors.map((err, idx) => ( +
  • + 조건 {err.conditionIndex + 1}과 조건 {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}열 중복 +
  • + ))} +
+ + 중복된 열 범위를 수정해주세요. + +
+
+ )} + + {/* 기존 데이터 중복 경고 */} + {hasDuplicateWithExisting && ( + + + + 이미 등록된 위치가 있습니다! +
    + {duplicateErrors.map((err, idx) => ( +
  • + {err.row}열: {err.existingLevels.join(", ")}단 (이미 등록됨) +
  • + ))} +
+ + 해당 열/단을 제외하거나 기존 데이터를 삭제해주세요. + +
+
+ )} + + {/* 기존 데이터 로딩 중 표시 */} + {isCheckingDuplicates && ( + + + + 기존 위치 데이터를 확인하는 중... + + + )} + + {/* 기존 데이터 존재 알림 */} + {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( + + + + 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. + + + )} + {/* 현재 매핑된 값 표시 */} {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
@@ -548,10 +861,11 @@ export const RackStructureComponent: React.FC = ({ variant="outline" size="sm" onClick={generatePreview} + disabled={hasDuplicateWithExisting || hasRowOverlap || missingFields.length > 0 || isCheckingDuplicates} className="h-8 gap-1" > - 미리보기 생성 + {isCheckingDuplicates ? "확인 중..." : hasDuplicateWithExisting ? "중복 있음" : "미리보기 생성"} @@ -595,15 +909,15 @@ export const RackStructureComponent: React.FC = ({ {previewData.map((loc, idx) => ( {idx + 1} - {loc.locationCode} - {loc.locationName} - {context?.floor || "1"} - {context?.zone || "A"} + {loc.location_code} + {loc.location_name} + {loc.floor || context?.floor || "1"} + {loc.zone || context?.zone || "A"} - {loc.rowNum.toString().padStart(2, "0")} + {loc.row_num.padStart(2, "0")} - {loc.levelNum} - {loc.locationType} + {loc.level_num} + {loc.location_type} - ))} diff --git a/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx b/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx index ab832f51..e33658b5 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx @@ -14,24 +14,40 @@ export class RackStructureRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = RackStructureDefinition; render(): React.ReactElement { - const { formData, isPreview, config } = this.props as any; + const { formData, isPreview, config, tableName, onFormDataChange } = this.props as Record; return ( } + tableName={tableName as string} + onChange={(locations) => + this.handleLocationsChange( + locations, + onFormDataChange as ((fieldName: string, value: unknown) => void) | undefined, + ) + } + isPreview={isPreview as boolean} /> ); } /** * 생성된 위치 데이터 변경 핸들러 + * formData에 _rackStructureLocations 키로 저장하여 저장 액션에서 감지 */ - protected handleLocationsChange = (locations: GeneratedLocation[]) => { - // 생성된 위치 데이터를 formData에 저장 + protected handleLocationsChange = ( + locations: GeneratedLocation[], + onFormDataChange?: (fieldName: string, value: unknown) => void, + ) => { + // 생성된 위치 데이터를 컴포넌트에 저장 this.updateComponent({ generatedLocations: locations }); + + // formData에도 저장하여 저장 액션에서 감지할 수 있도록 함 + if (onFormDataChange) { + console.log("📦 [RackStructure] 미리보기 데이터를 formData에 저장:", locations.length, "개"); + onFormDataChange("_rackStructureLocations", locations); + } }; } diff --git a/frontend/lib/registry/components/rack-structure/types.ts b/frontend/lib/registry/components/rack-structure/types.ts index 485a2208..5ab7bd7e 100644 --- a/frontend/lib/registry/components/rack-structure/types.ts +++ b/frontend/lib/registry/components/rack-structure/types.ts @@ -18,18 +18,19 @@ export interface RackStructureTemplate { createdAt?: string; } -// 생성될 위치 데이터 +// 생성될 위치 데이터 (테이블 컬럼명과 동일하게 매핑) export interface GeneratedLocation { - rowNum: number; // 열 번호 - levelNum: number; // 단 번호 - locationCode: string; // 위치 코드 (예: WH001-1A-01-1) - locationName: string; // 위치명 (예: A구역-01열-1단) - locationType?: string; // 위치 유형 - status?: string; // 사용 여부 + row_num: string; // 열 번호 (varchar) + level_num: string; // 단 번호 (varchar) + location_code: string; // 위치 코드 (예: WH001-1A-01-1) + location_name: string; // 위치명 (예: A구역-01열-1단) + location_type?: string; // 위치 유형 + status?: string; // 사용 여부 // 추가 필드 (상위 폼에서 매핑된 값) - warehouseCode?: string; - floor?: string; - zone?: string; + warehouse_id?: string; // 창고 ID/코드 + warehouse_name?: string; // 창고명 + floor?: string; // 층 + zone?: string; // 구역 } // 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 275efbb5..c5e86849 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -114,7 +114,7 @@ export interface ButtonActionConfig { geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active") geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - + // 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에) geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부 geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles") @@ -152,7 +152,7 @@ export interface ButtonActionConfig { updateTableName?: string; // 대상 테이블명 (다른 테이블 UPDATE 시) updateKeyField?: string; // 키 필드명 (WHERE 조건에 사용) updateKeySourceField?: string; // 키 값 소스 (폼 필드명 또는 __userId__ 등 특수 키워드) - + // 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용) updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부 updateGeolocationLatField?: string; // 위도 저장 필드 @@ -262,7 +262,7 @@ export interface ButtonActionContext { // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 - + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) splitPanelParentData?: Record; } @@ -276,10 +276,7 @@ export interface ButtonActionContext { * - __screenId__ : 현재 화면 ID * - __tableName__ : 현재 테이블명 */ -export function resolveSpecialKeyword( - sourceField: string | undefined, - context: ButtonActionContext -): any { +export function resolveSpecialKeyword(sourceField: string | undefined, context: ButtonActionContext): any { if (!sourceField) return undefined; // 특수 키워드 처리 @@ -416,6 +413,81 @@ export class ButtonActionExecutor { console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); + // 🆕 렉 구조 컴포넌트 일괄 저장 감지 + let rackStructureLocations: any[] | undefined; + let rackStructureFieldKey = "_rackStructureLocations"; + let hasEmptyRackStructureField = false; + + // formData에서 렉 구조 데이터 또는 빈 배열 찾기 + for (const [key, value] of Object.entries(context.formData || {})) { + // 배열인 경우만 체크 + if (Array.isArray(value)) { + if (value.length > 0 && value[0]) { + const firstItem = value[0]; + const isNewFormat = + firstItem.location_code && + firstItem.location_name && + firstItem.row_num !== undefined && + firstItem.level_num !== undefined; + const isOldFormat = + firstItem.locationCode && + firstItem.locationName && + firstItem.rowNum !== undefined && + firstItem.levelNum !== undefined; + + if (isNewFormat || isOldFormat) { + console.log("🏗️ [handleSave] 렉 구조 데이터 감지 - 필드:", key); + rackStructureLocations = value; + rackStructureFieldKey = key; + break; + } + } else if (value.length === 0 && key.startsWith("comp_")) { + // comp_로 시작하는 빈 배열은 렉 구조 컴포넌트일 가능성 있음 + // allComponents에서 확인 + const rackStructureComponentInLayout = context.allComponents?.find( + (comp: any) => + comp.type === "component" && comp.componentId === "rack-structure" && comp.columnName === key, + ); + if (rackStructureComponentInLayout) { + console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 (미리보기 없음) - 필드:", key); + hasEmptyRackStructureField = true; + rackStructureFieldKey = key; + } + } + } + } + + // 렉 구조 컴포넌트가 있지만 미리보기 데이터가 없는 경우 + if (hasEmptyRackStructureField && (!rackStructureLocations || rackStructureLocations.length === 0)) { + alert("미리보기를 먼저 생성해주세요.\n\n렉 구조 조건을 설정한 후 '미리보기 생성' 버튼을 클릭하세요."); + return false; + } + + // 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음) + // 이 경우 일반 저장을 차단하고 미리보기 생성을 요구 + const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.floor && + context.formData?.zone && + !rackStructureLocations; + + if (isRackStructureScreen) { + console.log("🏗️ [handleSave] 렉 구조 등록 화면 감지 - 미리보기 데이터 없음"); + alert( + "렉 구조 등록 화면입니다.\n\n" + + "미리보기를 먼저 생성해주세요.\n" + + "- 중복된 위치가 있으면 미리보기가 생성되지 않습니다.\n" + + "- 기존 데이터를 삭제하거나 다른 열/단을 선택해주세요.", + ); + return false; + } + + // 렉 구조 데이터가 있으면 일괄 저장 + if (rackStructureLocations && rackStructureLocations.length > 0) { + console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 - 일괄 저장 시작:", rackStructureLocations.length, "개"); + return await this.handleRackStructureBatchSave(config, context, rackStructureLocations, rackStructureFieldKey); + } + // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) console.log("🔍 [handleSave] formData 구조 확인:", { isFormDataArray: Array.isArray(context.formData), @@ -585,12 +657,12 @@ export class ButtonActionExecutor { if (Object.keys(fieldsWithNumbering).length > 0) { console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); - + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); const allocateResult = await allocateNumberingCode(ruleId); - + if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`); @@ -691,8 +763,8 @@ export class ButtonActionExecutor { } // 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리 - const repeatScreenModalKeys = Object.keys(context.formData).filter((key) => - key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations" + const repeatScreenModalKeys = Object.keys(context.formData).filter( + (key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations", ); // RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀 @@ -749,7 +821,7 @@ export class ButtonActionExecutor { console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta); const insertResult = await apiClient.post( `/table-management/tables/${targetTable}/add`, - dataWithMeta + dataWithMeta, ); console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data); } else if (id) { @@ -757,10 +829,10 @@ export class ButtonActionExecutor { const originalData = { id }; const updatedData = { ...dataWithMeta, id }; console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData }); - const updateResult = await apiClient.put( - `/table-management/tables/${targetTable}/edit`, - { originalData, updatedData } - ); + const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData, + updatedData, + }); console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data); } } catch (error: any) { @@ -794,12 +866,14 @@ export class ButtonActionExecutor { [joinKey.targetField]: sourceValue, }; - console.log(`📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`); - - const updateResult = await apiClient.put( - `/table-management/tables/${targetTable}/edit`, - { originalData, updatedData } + console.log( + `📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`, ); + + const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData, + updatedData, + }); console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data); } catch (error: any) { console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message); @@ -856,7 +930,7 @@ export class ButtonActionExecutor { // 복합키인 경우 로그 출력 if (primaryKeys.length > 1) { - console.log(`🔗 복합 기본키 감지:`, primaryKeys); + console.log("🔗 복합 기본키 감지:", primaryKeys); console.log(`📍 첫 번째 키 (${primaryKeyColumn}) 값을 사용: ${value}`); } @@ -908,6 +982,184 @@ export class ButtonActionExecutor { return await this.handleSave(config, context); } + /** + * 🆕 렉 구조 컴포넌트 일괄 저장 처리 + * 미리보기에서 생성된 위치 데이터를 일괄 INSERT + */ + private static async handleRackStructureBatchSave( + config: ButtonActionConfig, + context: ButtonActionContext, + locations: any[], + rackStructureFieldKey: string = "_rackStructureLocations", + ): Promise { + const { tableName, screenId, userId, companyCode } = context; + + console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 일괄 저장 시작:", { + locationsCount: locations.length, + tableName, + screenId, + rackStructureFieldKey, + }); + + if (!tableName) { + throw new Error("테이블명이 지정되지 않았습니다."); + } + + if (locations.length === 0) { + throw new Error("저장할 위치 데이터가 없습니다. 먼저 미리보기를 생성해주세요."); + } + + console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 데이터 예시:", locations[0]); + + // 저장 전 중복 체크 + const firstLocation = locations[0]; + const warehouseId = firstLocation.warehouse_id || firstLocation.warehouseCode; + const floor = firstLocation.floor; + const zone = firstLocation.zone; + + if (warehouseId && floor && zone) { + console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseId, floor, zone }); + + try { + const existingResponse = await DynamicFormApi.getTableData(tableName, { + filters: { + warehouse_id: warehouseId, + floor: floor, + zone: zone, + }, + page: 1, + pageSize: 1000, + }); + + // API 응답 구조에 따라 데이터 추출 + const responseData = existingResponse.data as any; + const existingData = responseData?.data || responseData || []; + + if (Array.isArray(existingData) && existingData.length > 0) { + // 중복되는 위치 확인 + const existingSet = new Set(existingData.map((loc: any) => `${loc.row_num}-${loc.level_num}`)); + + const duplicates = locations.filter((loc) => { + const key = `${loc.row_num || loc.rowNum}-${loc.level_num || loc.levelNum}`; + return existingSet.has(key); + }); + + if (duplicates.length > 0) { + const duplicateInfo = duplicates + .slice(0, 5) + .map((d) => `${d.row_num || d.rowNum}열 ${d.level_num || d.levelNum}단`) + .join(", "); + + const moreCount = duplicates.length > 5 ? ` 외 ${duplicates.length - 5}개` : ""; + + alert( + `이미 등록된 위치가 있습니다!\n\n중복 위치: ${duplicateInfo}${moreCount}\n\n해당 위치를 제외하거나 기존 데이터를 삭제해주세요.`, + ); + return false; + } + } + } catch (checkError) { + console.warn("⚠️ [handleRackStructureBatchSave] 중복 체크 실패 (저장 계속 진행):", checkError); + } + } + + // 각 위치 데이터를 그대로 저장 (렉 구조 컴포넌트에서 이미 테이블 컬럼명으로 생성됨) + const recordsToInsert = locations.map((loc) => { + // 렉 구조 컴포넌트에서 생성된 데이터를 그대로 사용 + // 새로운 형식(스네이크 케이스)과 기존 형식(카멜 케이스) 모두 지원 + const record: Record = { + // 렉 구조에서 생성된 필드 (이미 테이블 컬럼명과 동일) + location_code: loc.location_code || loc.locationCode, + location_name: loc.location_name || loc.locationName, + row_num: loc.row_num || String(loc.rowNum), + level_num: loc.level_num || String(loc.levelNum), + // 창고 정보 (렉 구조 컴포넌트에서 전달) + warehouse_id: loc.warehouse_id || loc.warehouseCode, + warehouse_name: loc.warehouse_name || loc.warehouseName, + // 위치 정보 (렉 구조 컴포넌트에서 전달) + floor: loc.floor, + zone: loc.zone, + location_type: loc.location_type || loc.locationType, + status: loc.status || "사용", + // 사용자 정보 추가 + writer: userId, + company_code: companyCode, + }; + + return record; + }); + + console.log("🏗️ [handleRackStructureBatchSave] 저장할 레코드 수:", recordsToInsert.length); + console.log("🏗️ [handleRackStructureBatchSave] 첫 번째 레코드 예시:", recordsToInsert[0]); + + // 일괄 INSERT 실행 + try { + let successCount = 0; + let errorCount = 0; + const errors: string[] = []; + + for (let i = 0; i < recordsToInsert.length; i++) { + const record = recordsToInsert[i]; + try { + console.log(`🏗️ [handleRackStructureBatchSave] 저장 중 (${i + 1}/${recordsToInsert.length}):`, record); + + const result = await DynamicFormApi.saveFormData({ + screenId, + tableName, + data: record, + }); + + console.log(`🏗️ [handleRackStructureBatchSave] API 응답 (${i + 1}):`, result); + + if (result.success) { + successCount++; + } else { + errorCount++; + const errorMsg = result.message || result.error || "알 수 없는 오류"; + errors.push(errorMsg); + console.error(`❌ [handleRackStructureBatchSave] 저장 실패 (${i + 1}):`, errorMsg); + } + } catch (error: any) { + errorCount++; + const errorMsg = error.message || "저장 중 오류 발생"; + errors.push(errorMsg); + console.error(`❌ [handleRackStructureBatchSave] 예외 발생 (${i + 1}):`, error); + } + } + + console.log("🏗️ [handleRackStructureBatchSave] 저장 완료:", { + successCount, + errorCount, + errors: errors.slice(0, 5), // 처음 5개 오류만 로그 + }); + + if (errorCount > 0) { + if (successCount > 0) { + alert(`${successCount}개 저장 완료, ${errorCount}개 저장 실패\n\n오류: ${errors.slice(0, 3).join("\n")}`); + } else { + throw new Error(`저장 실패: ${errors[0]}`); + } + } else { + alert(`${successCount}개의 위치가 성공적으로 등록되었습니다.`); + } + + // 성공 후 새로고침 + if (context.onRefresh) { + context.onRefresh(); + } + + // 모달 닫기 + if (context.onClose) { + context.onClose(); + } + + return successCount > 0; + } catch (error: any) { + console.error("🏗️ [handleRackStructureBatchSave] 일괄 저장 오류:", error); + throw error; + } + } + /** * 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조) * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장 @@ -919,7 +1171,7 @@ export class ButtonActionExecutor { ): Promise { const { formData, tableName, screenId, selectedRowsData, originalData } = context; - console.log(`🔍 [handleBatchSave] context 확인:`, { + console.log("🔍 [handleBatchSave] context 확인:", { hasSelectedRowsData: !!selectedRowsData, selectedRowsCount: selectedRowsData?.length || 0, hasOriginalData: !!originalData, @@ -1137,7 +1389,7 @@ export class ButtonActionExecutor { try { // 플로우 선택 데이터 우선 사용 - let dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + const dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("🔍 handleDelete - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), @@ -1207,7 +1459,7 @@ export class ButtonActionExecutor { if (idField) deleteId = rowData[idField]; } - console.log(`🔍 폴백 방법으로 ID 추출:`, deleteId); + console.log("🔍 폴백 방법으로 ID 추출:", deleteId); } console.log("선택된 행 데이터:", rowData); @@ -1237,7 +1489,7 @@ export class ButtonActionExecutor { } else { console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출"); context.onRefresh?.(); // 테이블 새로고침 - + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 window.dispatchEvent(new CustomEvent("refreshTable")); console.log("🔄 refreshTable 전역 이벤트 발생"); @@ -1264,11 +1516,11 @@ export class ButtonActionExecutor { } context.onRefresh?.(); - + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 window.dispatchEvent(new CustomEvent("refreshTable")); console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)"); - + toast.success(config.successMessage || "삭제되었습니다."); return true; } catch (error) { @@ -1550,12 +1802,12 @@ export class ButtonActionExecutor { // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) const rawParentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; - + // 🆕 필드 매핑 적용 (소스 컬럼 → 타겟 컬럼) - let parentData = { ...rawParentData }; + const parentData = { ...rawParentData }; if (config.fieldMappings && Array.isArray(config.fieldMappings) && config.fieldMappings.length > 0) { console.log("🔄 [openModalWithData] 필드 매핑 적용:", config.fieldMappings); - + config.fieldMappings.forEach((mapping: { sourceField: string; targetField: string }) => { if (mapping.sourceField && mapping.targetField && rawParentData[mapping.sourceField] !== undefined) { // 타겟 필드에 소스 필드 값 복사 @@ -1564,7 +1816,7 @@ export class ButtonActionExecutor { } }); } - + console.log("📦 [openModalWithData] 부모 데이터 전달:", { dataSourceId, rawParentData, @@ -1688,7 +1940,7 @@ export class ButtonActionExecutor { const { selectedRowsData, flowSelectedData } = context; // 플로우 선택 데이터 우선 사용 - let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + const dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; // 선택된 데이터가 없는 경우 if (!dataToEdit || dataToEdit.length === 0) { @@ -1868,7 +2120,7 @@ export class ButtonActionExecutor { const { selectedRowsData, flowSelectedData } = context; // 플로우 선택 데이터 우선 사용 - let dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + const dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("📋 handleCopy - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), @@ -1980,7 +2232,7 @@ export class ButtonActionExecutor { }); if (resetFieldName) { - toast.success(`복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다.`); + toast.success("복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다."); } else { console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다."); console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData)); @@ -2753,7 +3005,7 @@ export class ButtonActionExecutor { } else { console.warn(`⚠️ 매핑 실패: ${sourceField} → ${targetField} (값을 찾을 수 없음)`); console.warn(` - valueType: ${valueType}, defaultValue: ${defaultValue}`); - console.warn(` - 소스 데이터 키들:`, Object.keys(sourceData)); + console.warn(" - 소스 데이터 키들:", Object.keys(sourceData)); console.warn(` - sourceData[${sourceField}] =`, sourceData[sourceField]); return; // 값이 없으면 해당 필드는 스킵 } @@ -2791,7 +3043,7 @@ export class ButtonActionExecutor { if (result.success) { console.log("✅ 삽입 성공:", result); - toast.success(`데이터가 타겟 테이블에 성공적으로 삽입되었습니다.`); + toast.success("데이터가 타겟 테이블에 성공적으로 삽입되었습니다."); } else { throw new Error(result.message || "삽입 실패"); } @@ -3020,7 +3272,7 @@ export class ButtonActionExecutor { const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); if (layoutResponse.data?.success && layoutResponse.data?.data) { - let layoutData = layoutResponse.data.data; + const layoutData = layoutResponse.data.data; // components가 문자열이면 파싱 if (typeof layoutData.components === "string") { @@ -3455,13 +3707,13 @@ export class ButtonActionExecutor { const totalRows = preview.totalAffectedRows; const confirmMerge = confirm( - `⚠️ 코드 병합 확인\n\n` + + "⚠️ 코드 병합 확인\n\n" + `${oldValue} → ${newValue}\n\n` + - `영향받는 데이터:\n` + + "영향받는 데이터:\n" + `- 테이블 수: ${preview.preview.length}개\n` + `- 총 행 수: ${totalRows}개\n\n` + `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + - `계속하시겠습니까?`, + "계속하시겠습니까?", ); if (!confirmMerge) { @@ -3486,7 +3738,7 @@ export class ButtonActionExecutor { if (response.data.success) { const data = response.data.data; toast.success( - `코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, + "코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, ); // 화면 새로고침 @@ -3532,9 +3784,10 @@ export class ButtonActionExecutor { } // Trip ID 생성 - const tripId = config.trackingAutoGenerateTripId !== false - ? `TRIP_${Date.now()}_${context.userId || "unknown"}` - : context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`; + const tripId = + config.trackingAutoGenerateTripId !== false + ? `TRIP_${Date.now()}_${context.userId || "unknown"}` + : context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`; this.currentTripId = tripId; this.trackingContext = context; @@ -3565,7 +3818,7 @@ export class ButtonActionExecutor { const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); if (keyValue) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, @@ -3591,9 +3844,11 @@ export class ButtonActionExecutor { toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`); // 추적 시작 이벤트 발생 (UI 업데이트용) - window.dispatchEvent(new CustomEvent("trackingStarted", { - detail: { tripId, interval } - })); + window.dispatchEvent( + new CustomEvent("trackingStarted", { + detail: { tripId, interval }, + }), + ); return true; } catch (error: any) { @@ -3623,26 +3878,36 @@ export class ButtonActionExecutor { const tripId = this.currentTripId; // 마지막 위치 저장 (trip_status를 completed로) - const departure = this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; + const departure = + this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; - const vehicleId = this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; + const vehicleId = + this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; - await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed"); + await this.saveLocationToHistory( + tripId, + departure, + arrival, + departureName, + destinationName, + vehicleId, + "completed", + ); // 🆕 거리/시간 계산 및 저장 if (tripId) { try { const tripStats = await this.calculateTripStats(tripId); console.log("📊 운행 통계:", tripStats); - + // 운행 통계를 두 테이블에 저장 if (tripStats) { const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m const timeMinutes = tripStats.totalTimeMinutes; const userId = this.trackingUserId || context.userId; - + console.log("💾 운행 통계 DB 저장 시도:", { tripId, userId, @@ -3651,34 +3916,37 @@ export class ButtonActionExecutor { startTime: tripStats.startTime, endTime: tripStats.endTime, }); - + const { apiClient } = await import("@/lib/api/client"); - + // 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용) try { - const lastRecordResponse = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, { - page: 1, - size: 1, - search: { trip_id: tripId }, - sortBy: "recorded_at", - sortOrder: "desc", - autoFilter: true, - }); - + const lastRecordResponse = await apiClient.post( + "/table-management/tables/vehicle_location_history/data", + { + page: 1, + size: 1, + search: { trip_id: tripId }, + sortBy: "recorded_at", + sortOrder: "desc", + autoFilter: true, + }, + ); + const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || []; if (lastRecordData.length > 0) { const lastRecordId = lastRecordData[0].id; console.log("📍 마지막 레코드 ID:", lastRecordId); - + const historyUpdates = [ { field: "trip_distance", value: distanceMeters }, { field: "trip_time", value: timeMinutes }, { field: "trip_start", value: tripStats.startTime }, { field: "trip_end", value: tripStats.endTime }, ]; - + for (const update of historyUpdates) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: "vehicle_location_history", keyField: "id", keyValue: lastRecordId, @@ -3693,7 +3961,7 @@ export class ButtonActionExecutor { } catch (historyError) { console.warn("⚠️ vehicle_location_history 저장 실패:", historyError); } - + // 2️⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용) if (userId) { try { @@ -3703,9 +3971,9 @@ export class ButtonActionExecutor { { field: "last_trip_start", value: tripStats.startTime }, { field: "last_trip_end", value: tripStats.endTime }, ]; - + for (const update of vehicleUpdates) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: "vehicles", keyField: "user_id", keyValue: userId, @@ -3718,19 +3986,23 @@ export class ButtonActionExecutor { console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError); } } - + // 이벤트로 통계 전달 (UI에서 표시용) - window.dispatchEvent(new CustomEvent("tripCompleted", { - detail: { - tripId, - totalDistanceKm: tripStats.totalDistanceKm, - totalTimeMinutes: tripStats.totalTimeMinutes, - startTime: tripStats.startTime, - endTime: tripStats.endTime, - } - })); - - toast.success(`운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`); + window.dispatchEvent( + new CustomEvent("tripCompleted", { + detail: { + tripId, + totalDistanceKm: tripStats.totalDistanceKm, + totalTimeMinutes: tripStats.totalTimeMinutes, + startTime: tripStats.startTime, + endTime: tripStats.endTime, + }, + }), + ); + + toast.success( + `운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`, + ); } } catch (statsError) { console.warn("⚠️ 운행 통계 계산 실패:", statsError); @@ -3746,10 +4018,13 @@ export class ButtonActionExecutor { const { apiClient } = await import("@/lib/api/client"); const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName; const keyField = effectiveConfig.trackingStatusKeyField || "user_id"; - const keyValue = resolveSpecialKeyword(effectiveConfig.trackingStatusKeySourceField || "__userId__", effectiveContext); + const keyValue = resolveSpecialKeyword( + effectiveConfig.trackingStatusKeySourceField || "__userId__", + effectiveContext, + ); if (keyValue) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, @@ -3771,9 +4046,11 @@ export class ButtonActionExecutor { toast.success(config.successMessage || "위치 추적이 종료되었습니다."); // 추적 종료 이벤트 발생 (UI 업데이트용) - window.dispatchEvent(new CustomEvent("trackingStopped", { - detail: { tripId } - })); + window.dispatchEvent( + new CustomEvent("trackingStopped", { + detail: { tripId }, + }), + ); // 화면 새로고침 context.onRefresh?.(); @@ -3798,8 +4075,8 @@ export class ButtonActionExecutor { try { // vehicle_location_history에서 해당 trip의 모든 위치 조회 const { apiClient } = await import("@/lib/api/client"); - - const response = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, { + + const response = await apiClient.post("/table-management/tables/vehicle_location_history/data", { page: 1, size: 10000, search: { trip_id: tripId }, @@ -3814,7 +4091,7 @@ export class ButtonActionExecutor { // 응답 형식: data.data.data 또는 data.data.rows const rows = response.data?.data?.data || response.data?.data?.rows || []; - + if (!rows.length) { console.log("📊 통계 계산: 데이터 없음"); return null; @@ -3834,13 +4111,13 @@ export class ButtonActionExecutor { for (let i = 1; i < locations.length; i++) { const prev = locations[i - 1]; const curr = locations[i]; - + if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) { const distance = this.calculateDistance( parseFloat(prev.latitude), parseFloat(prev.longitude), parseFloat(curr.latitude), - parseFloat(curr.longitude) + parseFloat(curr.longitude), ); totalDistanceM += distance; } @@ -3874,12 +4151,11 @@ export class ButtonActionExecutor { */ private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; // 지구 반경 (미터) - const dLat = (lat2 - lat1) * Math.PI / 180; - const dLon = (lon2 - lon1) * Math.PI / 180; - const a = + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } @@ -3895,7 +4171,7 @@ export class ButtonActionExecutor { departureName: string | null, destinationName: string | null, vehicleId: number | null, - tripStatus: string = "active" + tripStatus: string = "active", ): Promise { return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( @@ -3925,7 +4201,7 @@ export class ButtonActionExecutor { console.log("📍 [saveLocationToHistory] 위치 저장:", locationData); // 1. vehicle_location_history에 저장 - const response = await apiClient.post(`/dynamic-form/location-history`, locationData); + const response = await apiClient.post("/dynamic-form/location-history", locationData); if (response.data?.success) { console.log("✅ 위치 이력 저장 성공:", response.data.data); @@ -3943,7 +4219,7 @@ export class ButtonActionExecutor { if (keyValue) { try { // latitude 업데이트 - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: vehiclesTableName, keyField, keyValue, @@ -3952,7 +4228,7 @@ export class ButtonActionExecutor { }); // longitude 업데이트 - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: vehiclesTableName, keyField, keyValue, @@ -3982,7 +4258,7 @@ export class ButtonActionExecutor { enableHighAccuracy: true, timeout: 10000, maximumAge: 0, - } + }, ); }); } @@ -4172,14 +4448,14 @@ export class ButtonActionExecutor { if (keyValue && targetTableName) { try { const { apiClient } = await import("@/lib/api/client"); - + // 위치 정보 필드들 업데이트 (위도, 경도, 정확도, 타임스탬프) const fieldsToUpdate = { ...updates }; - + // formData에서 departure, arrival만 포함 (테이블에 있을 가능성 높은 필드만) if (context.formData?.departure) fieldsToUpdate.departure = context.formData.departure; if (context.formData?.arrival) fieldsToUpdate.arrival = context.formData.arrival; - + // 추가 필드 변경 (status 등) if (config.geolocationExtraField && config.geolocationExtraValue !== undefined) { fieldsToUpdate[config.geolocationExtraField] = config.geolocationExtraValue; @@ -4191,7 +4467,7 @@ export class ButtonActionExecutor { let successCount = 0; for (const [field, value] of Object.entries(fieldsToUpdate)) { try { - const response = await apiClient.put(`/dynamic-form/update-field`, { + const response = await apiClient.put("/dynamic-form/update-field", { tableName: targetTableName, keyField, keyValue, @@ -4210,10 +4486,15 @@ export class ButtonActionExecutor { // 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록) if (config.emptyVehicleTracking !== false) { await this.startEmptyVehicleTracking(config, context, { - latitude, longitude, accuracy, speed, heading, altitude + latitude, + longitude, + accuracy, + speed, + heading, + altitude, }); } - + toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다."); } catch (saveError) { console.error("❌ 위치정보 자동 저장 실패:", saveError); @@ -4261,9 +4542,16 @@ export class ButtonActionExecutor { * 공차 상태에서 연속 위치 추적 시작 */ private static async startEmptyVehicleTracking( - config: ButtonActionConfig, + config: ButtonActionConfig, context: ButtonActionContext, - initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null } + initialPosition: { + latitude: number; + longitude: number; + accuracy: number | null; + speed: number | null; + heading: number | null; + altitude: number | null; + }, ): Promise { try { // 기존 추적이 있으면 중지 @@ -4273,7 +4561,7 @@ export class ButtonActionExecutor { } const { apiClient } = await import("@/lib/api/client"); - + // Trip ID 생성 (공차용) const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; this.emptyVehicleTripId = tripId; @@ -4315,7 +4603,7 @@ export class ButtonActionExecutor { this.emptyVehicleWatchId = navigator.geolocation.watchPosition( async (position) => { const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; - + try { await apiClient.post("/dynamic-form/location-history", { tripId: this.emptyVehicleTripId, @@ -4345,7 +4633,7 @@ export class ButtonActionExecutor { enableHighAccuracy: true, timeout: trackingInterval, maximumAge: 0, - } + }, ); console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId }); @@ -4435,26 +4723,30 @@ export class ButtonActionExecutor { * 운행알림 및 종료 액션 처리 * - 위치 수집 + 상태 변경 + 연속 추적 (시작/종료) */ - private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { + private static async handleOperationControl( + config: ButtonActionConfig, + context: ButtonActionContext, + ): Promise { try { console.log("🔄 운행알림/종료 액션 실행:", { config, context }); // 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만) // updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우 - const isStartMode = config.updateTrackingMode === "start" || - config.updateTargetValue === "active" || - config.updateTargetValue === "inactive"; - + const isStartMode = + config.updateTrackingMode === "start" || + config.updateTargetValue === "active" || + config.updateTargetValue === "inactive"; + if (isStartMode) { // 출발지/도착지 필드명 (기본값: departure, destination) const departureField = config.trackingDepartureField || "departure"; const destinationField = config.trackingArrivalField || "destination"; - + const departure = context.formData?.[departureField]; const destination = context.formData?.[destinationField]; - + console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination }); - + if (!departure || departure === "" || !destination || destination === "") { toast.error("출발지와 도착지를 먼저 선택해주세요."); return false; @@ -4570,7 +4862,7 @@ export class ButtonActionExecutor { } } catch (geoError: any) { toast.dismiss(loadingToastId); - + // GeolocationPositionError 처리 if (geoError.code === 1) { toast.error("위치 정보 접근이 거부되었습니다."); @@ -4602,11 +4894,11 @@ export class ButtonActionExecutor { const keyField = config.updateKeyField; const keySourceField = config.updateKeySourceField; const targetTableName = config.updateTableName || tableName; - + if (keyField && keySourceField) { // 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID) const keyValue = resolveSpecialKeyword(keySourceField, context); - + console.log("🔄 필드 값 변경 - 키 필드 사용:", { targetTable: targetTableName, keyField, @@ -4614,43 +4906,45 @@ export class ButtonActionExecutor { keyValue, updates, }); - + if (!keyValue) { console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue }); toast.error("레코드를 식별할 키 값이 없습니다."); return false; } - + try { // 각 필드에 대해 개별 UPDATE 호출 const { apiClient } = await import("@/lib/api/client"); - + for (const [field, value] of Object.entries(updates)) { console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`); - - const response = await apiClient.put(`/dynamic-form/update-field`, { + + const response = await apiClient.put("/dynamic-form/update-field", { tableName: targetTableName, keyField: keyField, keyValue: keyValue, updateField: field, updateValue: value, }); - + if (!response.data?.success) { console.error(`❌ ${field} 업데이트 실패:`, response.data); toast.error(`${field} 업데이트에 실패했습니다.`); return false; } } - + console.log("✅ 모든 필드 업데이트 성공"); toast.success(config.successMessage || "상태가 변경되었습니다."); - + // 테이블 새로고침 이벤트 발생 - window.dispatchEvent(new CustomEvent("refreshTableData", { - detail: { tableName: targetTableName } - })); - + window.dispatchEvent( + new CustomEvent("refreshTableData", { + detail: { tableName: targetTableName }, + }), + ); + return true; } catch (apiError) { console.error("❌ 필드 값 변경 API 호출 실패:", apiError); @@ -4658,7 +4952,7 @@ export class ButtonActionExecutor { return false; } } - + // onSave 콜백이 있으면 사용 if (onSave) { console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)"); From 7f44855bc14a4e8a568211899d9c7ff3655a8809 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Dec 2025 17:56:56 +0900 Subject: [PATCH 16/24] =?UTF-8?q?=EB=A0=89=EA=B5=AC=EC=A1=B0=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=93=B1=EB=A1=9D=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rack-structure/RackStructureComponent.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index 7ddd6326..d80fd2c7 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -25,6 +25,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { cn } from "@/lib/utils"; import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; +import { apiClient } from "@/lib/api/client"; import { RackStructureComponentProps, RackLineCondition, @@ -398,8 +399,8 @@ export const RackStructureComponent: React.FC = ({ // 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지) const warehouseCodeForQuery = context.warehouseCode; - const floorForQuery = context.floor; - const zoneForQuery = context.zone; + const floorForQuery = context.floor; // 라벨 값 (예: "1층") + const zoneForQuery = context.zone; // 라벨 값 (예: "A구역") // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) useEffect(() => { @@ -411,6 +412,7 @@ export const RackStructureComponent: React.FC = ({ }); // 필수 조건이 충족되지 않으면 기존 데이터 초기화 + // DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용 if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵"); setExistingLocations([]); @@ -421,27 +423,32 @@ export const RackStructureComponent: React.FC = ({ setIsCheckingDuplicates(true); try { // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 - const filterParams = { - warehouse_id: warehouseCodeForQuery, - floor: floorForQuery, - zone: zoneForQuery, + // DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링 + // equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용) + const searchParams = { + warehouse_id: { value: warehouseCodeForQuery, operator: "equals" }, + floor: { value: floorForQuery, operator: "equals" }, + zone: { value: zoneForQuery, operator: "equals" }, }; - console.log("🔍 기존 위치 데이터 조회 시작:", filterParams); + console.log("🔍 기존 위치 데이터 조회 시작 (정확한 일치):", searchParams); - const response = await DynamicFormApi.getTableData("warehouse_location", { - filters: filterParams, + // 직접 apiClient 사용하여 정확한 형식으로 요청 + // 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리 + const response = await apiClient.post(`/table-management/tables/warehouse_location/data`, { page: 1, - pageSize: 1000, // 충분히 큰 값 + size: 1000, // 충분히 큰 값 + search: searchParams, // 백엔드가 기대하는 형식 (equals 연산자로 정확한 일치) }); - console.log("🔍 기존 위치 데이터 응답:", response); + console.log("🔍 기존 위치 데이터 응답:", response.data); - // API 응답 구조: { success: true, data: [...] } 또는 { success: true, data: { data: [...] } } - const dataArray = Array.isArray(response.data) - ? response.data - : (response.data?.data || []); + // API 응답 구조: { success: true, data: { data: [...], total, ... } } + const responseData = response.data?.data || response.data; + const dataArray = Array.isArray(responseData) + ? responseData + : (responseData?.data || []); - if (response.success && dataArray.length > 0) { + if (dataArray.length > 0) { const existing = dataArray.map((item: any) => ({ row_num: item.row_num, level_num: item.level_num, From 660e889e23e3b211e2453f85cf676d783f397c84 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 14:34:18 +0900 Subject: [PATCH 17/24] =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=ED=95=98=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/tableHistoryController.ts | 8 ++++---- backend-node/src/services/dynamicFormService.ts | 10 +++++++++- frontend/components/common/TableHistoryModal.tsx | 11 ++++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/backend-node/src/controllers/tableHistoryController.ts b/backend-node/src/controllers/tableHistoryController.ts index a32f31ad..8a506626 100644 --- a/backend-node/src/controllers/tableHistoryController.ts +++ b/backend-node/src/controllers/tableHistoryController.ts @@ -67,7 +67,7 @@ export class TableHistoryController { const whereClause = whereConditions.join(" AND "); - // 이력 조회 쿼리 + // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결) const historyQuery = ` SELECT log_id, @@ -84,7 +84,7 @@ export class TableHistoryController { full_row_after FROM ${logTableName} WHERE ${whereClause} - ORDER BY changed_at DESC + ORDER BY log_id DESC LIMIT ${limitParam} OFFSET ${offsetParam} `; @@ -196,7 +196,7 @@ export class TableHistoryController { const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // 이력 조회 쿼리 + // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결) const historyQuery = ` SELECT log_id, @@ -213,7 +213,7 @@ export class TableHistoryController { full_row_after FROM ${logTableName} ${whereClause} - ORDER BY changed_at DESC + ORDER BY log_id DESC LIMIT ${limitParam} OFFSET ${offsetParam} `; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index d52c184f..99d6257c 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1178,7 +1178,15 @@ export class DynamicFormService { console.log("📝 실행할 DELETE SQL:", deleteQuery); console.log("📊 SQL 파라미터:", [id]); - const result = await query(deleteQuery, [id]); + // 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용) + const result = await transaction(async (client) => { + // 이력 트리거에서 사용할 사용자 정보 설정 + if (userId) { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + } + const res = await client.query(deleteQuery, [id]); + return res.rows; + }); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index f2970b4f..52c7e9b6 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -13,6 +13,13 @@ import { DialogTitle, DialogDescription, } from "@/components/ui/dialog"; +import { + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogDescription, +} from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -137,7 +144,9 @@ export function TableHistoryModal({ const formatDate = (dateString: string) => { try { - return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); + // DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환 + const date = new Date(dateString); + return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); } catch { return dateString; } From 1f28add0ed7ef901fe89d3e35dc41f90ebf26903 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 16:01:59 +0900 Subject: [PATCH 18/24] =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EB=B0=9C=ED=96=89=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/taxInvoiceController.ts | 331 +++++++ backend-node/src/middleware/errorHandler.ts | 12 +- backend-node/src/routes/taxInvoiceRoutes.ts | 40 + .../src/services/taxInvoiceService.ts | 612 +++++++++++++ .../tax-invoice/TaxInvoiceDetail.tsx | 621 +++++++++++++ .../components/tax-invoice/TaxInvoiceForm.tsx | 706 +++++++++++++++ .../components/tax-invoice/TaxInvoiceList.tsx | 818 ++++++++++++++++++ frontend/components/tax-invoice/index.ts | 4 + frontend/lib/api/taxInvoice.ts | 229 +++++ frontend/lib/registry/components/index.ts | 3 + .../TaxInvoiceListComponent.tsx | 48 + .../TaxInvoiceListConfigPanel.tsx | 166 ++++ .../TaxInvoiceListRenderer.tsx | 32 + .../components/tax-invoice-list/index.ts | 37 + .../components/tax-invoice-list/types.ts | 41 + 16 files changed, 3701 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/controllers/taxInvoiceController.ts create mode 100644 backend-node/src/routes/taxInvoiceRoutes.ts create mode 100644 backend-node/src/services/taxInvoiceService.ts create mode 100644 frontend/components/tax-invoice/TaxInvoiceDetail.tsx create mode 100644 frontend/components/tax-invoice/TaxInvoiceForm.tsx create mode 100644 frontend/components/tax-invoice/TaxInvoiceList.tsx create mode 100644 frontend/components/tax-invoice/index.ts create mode 100644 frontend/lib/api/taxInvoice.ts create mode 100644 frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx create mode 100644 frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx create mode 100644 frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx create mode 100644 frontend/lib/registry/components/tax-invoice-list/index.ts create mode 100644 frontend/lib/registry/components/tax-invoice-list/types.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d36ad8c3..53a4fa4d 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -75,6 +75,7 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 +import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -245,6 +246,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 +app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/taxInvoiceController.ts b/backend-node/src/controllers/taxInvoiceController.ts new file mode 100644 index 00000000..588a856c --- /dev/null +++ b/backend-node/src/controllers/taxInvoiceController.ts @@ -0,0 +1,331 @@ +/** + * 세금계산서 컨트롤러 + * 세금계산서 API 엔드포인트 처리 + */ + +import { Request, Response } from "express"; +import { TaxInvoiceService } from "../services/taxInvoiceService"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + }; +} + +export class TaxInvoiceController { + /** + * 세금계산서 목록 조회 + * GET /api/tax-invoice + */ + static async getList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { + page = "1", + pageSize = "20", + invoice_type, + invoice_status, + start_date, + end_date, + search, + buyer_name, + } = req.query; + + const result = await TaxInvoiceService.getList(companyCode, { + page: parseInt(page as string, 10), + pageSize: parseInt(pageSize as string, 10), + invoice_type: invoice_type as "sales" | "purchase" | undefined, + invoice_status: invoice_status as string | undefined, + start_date: start_date as string | undefined, + end_date: end_date as string | undefined, + search: search as string | undefined, + buyer_name: buyer_name as string | undefined, + }); + + res.json({ + success: true, + data: result.data, + pagination: { + page: result.page, + pageSize: result.pageSize, + total: result.total, + totalPages: Math.ceil(result.total / result.pageSize), + }, + }); + } catch (error: any) { + logger.error("세금계산서 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 상세 조회 + * GET /api/tax-invoice/:id + */ + static async getById(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const result = await TaxInvoiceService.getById(id, companyCode); + + if (!result) { + res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." }); + return; + } + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("세금계산서 상세 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 조회 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 생성 + * POST /api/tax-invoice + */ + static async create(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const data = req.body; + + // 필수 필드 검증 + if (!data.invoice_type) { + res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." }); + return; + } + if (!data.invoice_date) { + res.status(400).json({ success: false, message: "작성일자는 필수입니다." }); + return; + } + if (data.supply_amount === undefined || data.supply_amount === null) { + res.status(400).json({ success: false, message: "공급가액은 필수입니다." }); + return; + } + + const result = await TaxInvoiceService.create(data, companyCode, userId); + + res.status(201).json({ + success: true, + data: result, + message: "세금계산서가 생성되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 생성 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 생성 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 수정 + * PUT /api/tax-invoice/:id + */ + static async update(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const data = req.body; + + const result = await TaxInvoiceService.update(id, data, companyCode, userId); + + if (!result) { + res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." }); + return; + } + + res.json({ + success: true, + data: result, + message: "세금계산서가 수정되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 수정 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 수정 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 삭제 + * DELETE /api/tax-invoice/:id + */ + static async delete(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const result = await TaxInvoiceService.delete(id, companyCode, userId); + + if (!result) { + res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." }); + return; + } + + res.json({ + success: true, + message: "세금계산서가 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 삭제 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 발행 + * POST /api/tax-invoice/:id/issue + */ + static async issue(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const result = await TaxInvoiceService.issue(id, companyCode, userId); + + if (!result) { + res.status(404).json({ + success: false, + message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.", + }); + return; + } + + res.json({ + success: true, + data: result, + message: "세금계산서가 발행되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 발행 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 발행 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 취소 + * POST /api/tax-invoice/:id/cancel + */ + static async cancel(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const { reason } = req.body; + + const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason); + + if (!result) { + res.status(404).json({ + success: false, + message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.", + }); + return; + } + + res.json({ + success: true, + data: result, + message: "세금계산서가 취소되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 취소 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 취소 중 오류가 발생했습니다.", + }); + } + } + + /** + * 월별 통계 조회 + * GET /api/tax-invoice/stats/monthly + */ + static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { year, month } = req.query; + const now = new Date(); + const targetYear = year ? parseInt(year as string, 10) : now.getFullYear(); + const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1; + + const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth); + + res.json({ + success: true, + data: result, + period: { year: targetYear, month: targetMonth }, + }); + } catch (error: any) { + logger.error("월별 통계 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "통계 조회 중 오류가 발생했습니다.", + }); + } + } +} + diff --git a/backend-node/src/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index 611e5d08..54d8f0a2 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -28,6 +28,16 @@ export const errorHandler = ( // PostgreSQL 에러 처리 (pg 라이브러리) if ((err as any).code) { const pgError = err as any; + // 원본 에러 메시지 로깅 (디버깅용) + console.error("🔴 PostgreSQL Error:", { + code: pgError.code, + message: pgError.message, + detail: pgError.detail, + hint: pgError.hint, + table: pgError.table, + column: pgError.column, + constraint: pgError.constraint, + }); // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html if (pgError.code === "23505") { // unique_violation @@ -42,7 +52,7 @@ export const errorHandler = ( // 기타 무결성 제약 조건 위반 error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); } else { - error = new AppError("데이터베이스 오류가 발생했습니다.", 500); + error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500); } } diff --git a/backend-node/src/routes/taxInvoiceRoutes.ts b/backend-node/src/routes/taxInvoiceRoutes.ts new file mode 100644 index 00000000..aa663faf --- /dev/null +++ b/backend-node/src/routes/taxInvoiceRoutes.ts @@ -0,0 +1,40 @@ +/** + * 세금계산서 라우터 + * /api/tax-invoice 경로 처리 + */ + +import { Router } from "express"; +import { TaxInvoiceController } from "../controllers/taxInvoiceController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 목록 조회 +router.get("/", TaxInvoiceController.getList); + +// 월별 통계 (상세 조회보다 먼저 정의해야 함) +router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats); + +// 상세 조회 +router.get("/:id", TaxInvoiceController.getById); + +// 생성 +router.post("/", TaxInvoiceController.create); + +// 수정 +router.put("/:id", TaxInvoiceController.update); + +// 삭제 +router.delete("/:id", TaxInvoiceController.delete); + +// 발행 +router.post("/:id/issue", TaxInvoiceController.issue); + +// 취소 +router.post("/:id/cancel", TaxInvoiceController.cancel); + +export default router; + diff --git a/backend-node/src/services/taxInvoiceService.ts b/backend-node/src/services/taxInvoiceService.ts new file mode 100644 index 00000000..63e94d5e --- /dev/null +++ b/backend-node/src/services/taxInvoiceService.ts @@ -0,0 +1,612 @@ +/** + * 세금계산서 서비스 + * 세금계산서 CRUD 및 비즈니스 로직 처리 + */ + +import { query, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// 세금계산서 타입 정의 +export interface TaxInvoice { + id: string; + company_code: string; + invoice_number: string; + invoice_type: "sales" | "purchase"; // 매출/매입 + invoice_status: "draft" | "issued" | "sent" | "cancelled"; + + // 공급자 정보 + supplier_business_no: string; + supplier_name: string; + supplier_ceo_name: string; + supplier_address: string; + supplier_business_type: string; + supplier_business_item: string; + + // 공급받는자 정보 + buyer_business_no: string; + buyer_name: string; + buyer_ceo_name: string; + buyer_address: string; + buyer_email: string; + + // 금액 정보 + supply_amount: number; + tax_amount: number; + total_amount: number; + + // 날짜 정보 + invoice_date: string; + issue_date: string | null; + + // 기타 + remarks: string; + order_id: string | null; + customer_id: string | null; + + // 첨부파일 (JSON 배열로 저장) + attachments: TaxInvoiceAttachment[] | null; + + created_date: string; + updated_date: string; + writer: string; +} + +// 첨부파일 타입 +export interface TaxInvoiceAttachment { + id: string; + file_name: string; + file_path: string; + file_size: number; + file_type: string; + uploaded_at: string; + uploaded_by: string; +} + +export interface TaxInvoiceItem { + id: string; + tax_invoice_id: string; + company_code: string; + item_seq: number; + item_date: string; + item_name: string; + item_spec: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks: string; +} + +export interface CreateTaxInvoiceDto { + invoice_type: "sales" | "purchase"; + supplier_business_no?: string; + supplier_name?: string; + supplier_ceo_name?: string; + supplier_address?: string; + supplier_business_type?: string; + supplier_business_item?: string; + buyer_business_no?: string; + buyer_name?: string; + buyer_ceo_name?: string; + buyer_address?: string; + buyer_email?: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + remarks?: string; + order_id?: string; + customer_id?: string; + items?: CreateTaxInvoiceItemDto[]; + attachments?: TaxInvoiceAttachment[]; // 첨부파일 +} + +export interface CreateTaxInvoiceItemDto { + item_date?: string; + item_name: string; + item_spec?: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks?: string; +} + +export interface TaxInvoiceListParams { + page?: number; + pageSize?: number; + invoice_type?: "sales" | "purchase"; + invoice_status?: string; + start_date?: string; + end_date?: string; + search?: string; + buyer_name?: string; +} + +export class TaxInvoiceService { + /** + * 세금계산서 번호 채번 + * 형식: YYYYMM-NNNNN (예: 202512-00001) + */ + static async generateInvoiceNumber(companyCode: string): Promise { + const now = new Date(); + const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`; + const prefix = `${yearMonth}-`; + + // 해당 월의 마지막 번호 조회 + const result = await query<{ max_num: string }>( + `SELECT invoice_number as max_num + FROM tax_invoice + WHERE company_code = $1 + AND invoice_number LIKE $2 + ORDER BY invoice_number DESC + LIMIT 1`, + [companyCode, `${prefix}%`] + ); + + let nextNum = 1; + if (result.length > 0 && result[0].max_num) { + const lastNum = parseInt(result[0].max_num.split("-")[1], 10); + nextNum = lastNum + 1; + } + + return `${prefix}${String(nextNum).padStart(5, "0")}`; + } + + /** + * 세금계산서 목록 조회 + */ + static async getList( + companyCode: string, + params: TaxInvoiceListParams + ): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> { + const { + page = 1, + pageSize = 20, + invoice_type, + invoice_status, + start_date, + end_date, + search, + buyer_name, + } = params; + + const offset = (page - 1) * pageSize; + const conditions: string[] = ["company_code = $1"]; + const values: any[] = [companyCode]; + let paramIndex = 2; + + if (invoice_type) { + conditions.push(`invoice_type = $${paramIndex}`); + values.push(invoice_type); + paramIndex++; + } + + if (invoice_status) { + conditions.push(`invoice_status = $${paramIndex}`); + values.push(invoice_status); + paramIndex++; + } + + if (start_date) { + conditions.push(`invoice_date >= $${paramIndex}`); + values.push(start_date); + paramIndex++; + } + + if (end_date) { + conditions.push(`invoice_date <= $${paramIndex}`); + values.push(end_date); + paramIndex++; + } + + if (search) { + conditions.push( + `(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})` + ); + values.push(`%${search}%`); + paramIndex++; + } + + if (buyer_name) { + conditions.push(`buyer_name ILIKE $${paramIndex}`); + values.push(`%${buyer_name}%`); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // 전체 개수 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`, + values + ); + const total = parseInt(countResult[0]?.count || "0", 10); + + // 데이터 조회 + values.push(pageSize, offset); + const data = await query( + `SELECT * FROM tax_invoice + WHERE ${whereClause} + ORDER BY created_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + values + ); + + return { data, total, page, pageSize }; + } + + /** + * 세금계산서 상세 조회 (품목 포함) + */ + static async getById( + id: string, + companyCode: string + ): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> { + const invoiceResult = await query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (invoiceResult.length === 0) { + return null; + } + + const items = await query( + `SELECT * FROM tax_invoice_item + WHERE tax_invoice_id = $1 AND company_code = $2 + ORDER BY item_seq`, + [id, companyCode] + ); + + return { invoice: invoiceResult[0], items }; + } + + /** + * 세금계산서 생성 + */ + static async create( + data: CreateTaxInvoiceDto, + companyCode: string, + userId: string + ): Promise { + return await transaction(async (client) => { + // 세금계산서 번호 채번 + const invoiceNumber = await this.generateInvoiceNumber(companyCode); + + // 세금계산서 생성 + const invoiceResult = await client.query( + `INSERT INTO tax_invoice ( + company_code, invoice_number, invoice_type, invoice_status, + supplier_business_no, supplier_name, supplier_ceo_name, supplier_address, + supplier_business_type, supplier_business_item, + buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email, + supply_amount, tax_amount, total_amount, invoice_date, + remarks, order_id, customer_id, attachments, writer + ) VALUES ( + $1, $2, $3, 'draft', + $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20, $21, $22, $23 + ) RETURNING *`, + [ + companyCode, + invoiceNumber, + data.invoice_type, + data.supplier_business_no || null, + data.supplier_name || null, + data.supplier_ceo_name || null, + data.supplier_address || null, + data.supplier_business_type || null, + data.supplier_business_item || null, + data.buyer_business_no || null, + data.buyer_name || null, + data.buyer_ceo_name || null, + data.buyer_address || null, + data.buyer_email || null, + data.supply_amount, + data.tax_amount, + data.total_amount, + data.invoice_date, + data.remarks || null, + data.order_id || null, + data.customer_id || null, + data.attachments ? JSON.stringify(data.attachments) : null, + userId, + ] + ); + + const invoice = invoiceResult.rows[0]; + + // 품목 생성 + if (data.items && data.items.length > 0) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + await client.query( + `INSERT INTO tax_invoice_item ( + tax_invoice_id, company_code, item_seq, + item_date, item_name, item_spec, quantity, unit_price, + supply_amount, tax_amount, remarks + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + invoice.id, + companyCode, + i + 1, + item.item_date || null, + item.item_name, + item.item_spec || null, + item.quantity, + item.unit_price, + item.supply_amount, + item.tax_amount, + item.remarks || null, + ] + ); + } + } + + logger.info("세금계산서 생성 완료", { + invoiceId: invoice.id, + invoiceNumber, + companyCode, + userId, + }); + + return invoice; + }); + } + + /** + * 세금계산서 수정 + */ + static async update( + id: string, + data: Partial, + companyCode: string, + userId: string + ): Promise { + return await transaction(async (client) => { + // 기존 세금계산서 확인 + const existing = await client.query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (existing.rows.length === 0) { + return null; + } + + // 발행된 세금계산서는 수정 불가 + if (existing.rows[0].invoice_status !== "draft") { + throw new Error("발행된 세금계산서는 수정할 수 없습니다."); + } + + // 세금계산서 수정 + const updateResult = await client.query( + `UPDATE tax_invoice SET + supplier_business_no = COALESCE($3, supplier_business_no), + supplier_name = COALESCE($4, supplier_name), + supplier_ceo_name = COALESCE($5, supplier_ceo_name), + supplier_address = COALESCE($6, supplier_address), + supplier_business_type = COALESCE($7, supplier_business_type), + supplier_business_item = COALESCE($8, supplier_business_item), + buyer_business_no = COALESCE($9, buyer_business_no), + buyer_name = COALESCE($10, buyer_name), + buyer_ceo_name = COALESCE($11, buyer_ceo_name), + buyer_address = COALESCE($12, buyer_address), + buyer_email = COALESCE($13, buyer_email), + supply_amount = COALESCE($14, supply_amount), + tax_amount = COALESCE($15, tax_amount), + total_amount = COALESCE($16, total_amount), + invoice_date = COALESCE($17, invoice_date), + remarks = COALESCE($18, remarks), + attachments = $19, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING *`, + [ + id, + companyCode, + data.supplier_business_no, + data.supplier_name, + data.supplier_ceo_name, + data.supplier_address, + data.supplier_business_type, + data.supplier_business_item, + data.buyer_business_no, + data.buyer_name, + data.buyer_ceo_name, + data.buyer_address, + data.buyer_email, + data.supply_amount, + data.tax_amount, + data.total_amount, + data.invoice_date, + data.remarks, + data.attachments ? JSON.stringify(data.attachments) : null, + ] + ); + + // 품목 업데이트 (기존 삭제 후 재생성) + if (data.items) { + await client.query( + `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, + [id, companyCode] + ); + + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + await client.query( + `INSERT INTO tax_invoice_item ( + tax_invoice_id, company_code, item_seq, + item_date, item_name, item_spec, quantity, unit_price, + supply_amount, tax_amount, remarks + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + id, + companyCode, + i + 1, + item.item_date || null, + item.item_name, + item.item_spec || null, + item.quantity, + item.unit_price, + item.supply_amount, + item.tax_amount, + item.remarks || null, + ] + ); + } + } + + logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId }); + + return updateResult.rows[0]; + }); + } + + /** + * 세금계산서 삭제 + */ + static async delete(id: string, companyCode: string, userId: string): Promise { + return await transaction(async (client) => { + // 기존 세금계산서 확인 + const existing = await client.query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (existing.rows.length === 0) { + return false; + } + + // 발행된 세금계산서는 삭제 불가 + if (existing.rows[0].invoice_status !== "draft") { + throw new Error("발행된 세금계산서는 삭제할 수 없습니다."); + } + + // 품목 삭제 + await client.query( + `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, + [id, companyCode] + ); + + // 세금계산서 삭제 + await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [ + id, + companyCode, + ]); + + logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId }); + + return true; + }); + } + + /** + * 세금계산서 발행 (상태 변경) + */ + static async issue(id: string, companyCode: string, userId: string): Promise { + const result = await query( + `UPDATE tax_invoice SET + invoice_status = 'issued', + issue_date = NOW(), + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft' + RETURNING *`, + [id, companyCode] + ); + + if (result.length === 0) { + return null; + } + + logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId }); + + return result[0]; + } + + /** + * 세금계산서 취소 + */ + static async cancel( + id: string, + companyCode: string, + userId: string, + reason?: string + ): Promise { + const result = await query( + `UPDATE tax_invoice SET + invoice_status = 'cancelled', + remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued') + RETURNING *`, + [id, companyCode, reason || null] + ); + + if (result.length === 0) { + return null; + } + + logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason }); + + return result[0]; + } + + /** + * 월별 통계 조회 + */ + static async getMonthlyStats( + companyCode: string, + year: number, + month: number + ): Promise<{ + sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + }> { + const startDate = `${year}-${String(month).padStart(2, "0")}-01`; + const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날 + + const result = await query<{ + invoice_type: string; + count: string; + supply_amount: string; + tax_amount: string; + total_amount: string; + }>( + `SELECT + invoice_type, + COUNT(*) as count, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE company_code = $1 + AND invoice_date >= $2 + AND invoice_date <= $3 + AND invoice_status != 'cancelled' + GROUP BY invoice_type`, + [companyCode, startDate, endDate] + ); + + const stats = { + sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, + purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, + }; + + for (const row of result) { + const type = row.invoice_type as "sales" | "purchase"; + stats[type] = { + count: parseInt(row.count, 10), + supply_amount: parseFloat(row.supply_amount), + tax_amount: parseFloat(row.tax_amount), + total_amount: parseFloat(row.total_amount), + }; + } + + return stats; + } +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceDetail.tsx b/frontend/components/tax-invoice/TaxInvoiceDetail.tsx new file mode 100644 index 00000000..9fe45e91 --- /dev/null +++ b/frontend/components/tax-invoice/TaxInvoiceDetail.tsx @@ -0,0 +1,621 @@ +"use client"; + +/** + * 세금계산서 상세 보기 컴포넌트 + * PDF 출력 및 첨부파일 다운로드 기능 포함 + */ + +import { useState, useEffect, useRef } from "react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Printer, + Download, + FileText, + Image, + File, + Loader2, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; + +import { + getTaxInvoiceById, + TaxInvoice, + TaxInvoiceItem, + TaxInvoiceAttachment, +} from "@/lib/api/taxInvoice"; +import { apiClient } from "@/lib/api/client"; + +interface TaxInvoiceDetailProps { + open: boolean; + onClose: () => void; + invoiceId: string; +} + +// 상태 라벨 +const statusLabels: Record = { + draft: "임시저장", + issued: "발행완료", + sent: "전송완료", + cancelled: "취소됨", +}; + +// 상태 색상 +const statusColors: Record = { + draft: "bg-gray-100 text-gray-800", + issued: "bg-green-100 text-green-800", + sent: "bg-blue-100 text-blue-800", + cancelled: "bg-red-100 text-red-800", +}; + +export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) { + const [invoice, setInvoice] = useState(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [pdfLoading, setPdfLoading] = useState(false); + const printRef = useRef(null); + + // 데이터 로드 + useEffect(() => { + if (open && invoiceId) { + loadData(); + } + }, [open, invoiceId]); + + const loadData = async () => { + setLoading(true); + try { + const response = await getTaxInvoiceById(invoiceId); + if (response.success) { + setInvoice(response.data.invoice); + setItems(response.data.items); + } + } catch (error: any) { + toast.error("데이터 로드 실패", { description: error.message }); + } finally { + setLoading(false); + } + }; + + // 금액 포맷 + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + // 날짜 포맷 + const formatDate = (dateString: string | null) => { + if (!dateString) return "-"; + try { + return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko }); + } catch { + return dateString; + } + }; + + // 파일 미리보기 URL 생성 (objid 기반) - 이미지용 + const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => { + // objid가 숫자형이면 API를 통해 미리보기 + if (attachment.id && !attachment.id.includes("-")) { + // apiClient의 baseURL 사용 + const baseURL = apiClient.defaults.baseURL || ""; + return `${baseURL}/files/preview/${attachment.id}`; + } + return attachment.file_path; + }; + + // 공통 인쇄용 HTML 생성 함수 + const generatePrintHtml = (autoPrint: boolean = false) => { + if (!invoice) return ""; + + const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입"; + const itemsHtml = items.map((item, index) => ` + + ${index + 1} + ${item.item_date?.split("T")[0] || "-"} + ${item.item_name} + ${item.item_spec || "-"} + ${item.quantity} + ${formatAmount(item.unit_price)} + ${formatAmount(item.supply_amount)} + ${formatAmount(item.tax_amount)} + + `).join(""); + + return ` + + + + 세금계산서_${invoice.invoice_number} + + + +
+
+

세금계산서 (${invoiceTypeText})

+
계산서번호: ${invoice.invoice_number}
+ ${statusLabels[invoice.invoice_status]} +
+ +
+
+

공급자

+
사업자번호${invoice.supplier_business_no || "-"}
+
상호${invoice.supplier_name || "-"}
+
대표자${invoice.supplier_ceo_name || "-"}
+
업태/종목${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}
+
주소${invoice.supplier_address || "-"}
+
+
+

공급받는자

+
사업자번호${invoice.buyer_business_no || "-"}
+
상호${invoice.buyer_name || "-"}
+
대표자${invoice.buyer_ceo_name || "-"}
+
이메일${invoice.buyer_email || "-"}
+
주소${invoice.buyer_address || "-"}
+
+
+ +
+

품목 내역

+ + + + + + + + + + + + + + + ${itemsHtml || ''} + +
No일자품목명규격수량단가공급가액세액
품목 내역이 없습니다.
+
+ +
+
+
공급가액${formatAmount(invoice.supply_amount)}원
+
세액${formatAmount(invoice.tax_amount)}원
+
합계금액${formatAmount(invoice.total_amount)}원
+
+
+ + ${invoice.remarks ? `
비고: ${invoice.remarks}
` : ""} + + ${invoice.attachments && invoice.attachments.length > 0 ? ` +
+

첨부파일 (${invoice.attachments.length}개)

+
    + ${invoice.attachments.map(file => `
  • 📄 ${file.file_name}
  • `).join("")} +
+
+ ` : ""} + + +
+ + ${autoPrint ? `` : ""} + + + `; + }; + + // 인쇄 + const handlePrint = () => { + if (!invoice) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) { + toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); + return; + } + + printWindow.document.write(generatePrintHtml(true)); + printWindow.document.close(); + }; + + // PDF 다운로드 (인쇄 다이얼로그 사용) + const handleDownloadPdf = async () => { + if (!invoice) return; + + setPdfLoading(true); + try { + const printWindow = window.open("", "_blank"); + if (!printWindow) { + toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); + return; + } + + printWindow.document.write(generatePrintHtml(true)); + printWindow.document.close(); + toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요."); + } catch (error: any) { + console.error("PDF 생성 오류:", error); + toast.error("PDF 생성 실패", { description: error.message }); + } finally { + setPdfLoading(false); + } + }; + + // 파일 아이콘 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return ; + if (fileType.includes("pdf")) return ; + return ; + }; + + // 파일 다운로드 (인증 토큰 포함) + const handleDownload = async (attachment: TaxInvoiceAttachment) => { + try { + // objid가 숫자형이면 API를 통해 다운로드 + if (attachment.id && !attachment.id.includes("-")) { + const response = await apiClient.get(`/files/download/${attachment.id}`, { + responseType: "blob", + }); + + // Blob으로 다운로드 + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = attachment.file_name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } else { + // 직접 경로로 다운로드 + window.open(attachment.file_path, "_blank"); + } + } catch (error: any) { + toast.error("파일 다운로드 실패", { description: error.message }); + } + }; + + if (loading) { + return ( + !o && onClose()}> + + + 세금계산서 상세 + +
+ 로딩 중... +
+
+
+ ); + } + + if (!invoice) { + return null; + } + + return ( + !o && onClose()}> + + + 세금계산서 상세 +
+ + +
+
+ + +
+
+ {/* 헤더 */} +
+

+ {invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"} +

+

+ 계산서번호: {invoice.invoice_number} +

+ + {statusLabels[invoice.invoice_status]} + +
+ + {/* 공급자 / 공급받는자 정보 */} +
+ {/* 공급자 */} +
+

공급자

+
+
+ 사업자번호 + {invoice.supplier_business_no || "-"} +
+
+ 상호 + {invoice.supplier_name || "-"} +
+
+ 대표자 + {invoice.supplier_ceo_name || "-"} +
+
+ 업태/종목 + + {invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"} + +
+
+ 주소 + {invoice.supplier_address || "-"} +
+
+
+ + {/* 공급받는자 */} +
+

공급받는자

+
+
+ 사업자번호 + {invoice.buyer_business_no || "-"} +
+
+ 상호 + {invoice.buyer_name || "-"} +
+
+ 대표자 + {invoice.buyer_ceo_name || "-"} +
+
+ 이메일 + {invoice.buyer_email || "-"} +
+
+ 주소 + {invoice.buyer_address || "-"} +
+
+
+
+ + {/* 품목 내역 */} +
+

품목 내역

+ + + + No + 일자 + 품목명 + 규격 + 수량 + 단가 + 공급가액 + 세액 + + + + {items.length > 0 ? ( + items.map((item, index) => ( + + {index + 1} + {item.item_date?.split("T")[0] || "-"} + {item.item_name} + {item.item_spec || "-"} + {item.quantity} + + {formatAmount(item.unit_price)} + + + {formatAmount(item.supply_amount)} + + + {formatAmount(item.tax_amount)} + + + )) + ) : ( + + + 품목 내역이 없습니다. + + + )} + +
+
+ + {/* 합계 */} +
+
+
+ 공급가액 + {formatAmount(invoice.supply_amount)}원 +
+
+ 세액 + {formatAmount(invoice.tax_amount)}원 +
+ +
+ 합계금액 + + {formatAmount(invoice.total_amount)}원 + +
+
+
+ + {/* 비고 */} + {invoice.remarks && ( +
+

비고

+

+ {invoice.remarks} +

+
+ )} + + {/* 날짜 정보 */} +
+ 작성일: {formatDate(invoice.invoice_date)} + {invoice.issue_date && 발행일: {formatDate(invoice.issue_date)}} +
+
+ + {/* 첨부파일 */} + {invoice.attachments && invoice.attachments.length > 0 && ( +
+ +

첨부파일 ({invoice.attachments.length}개)

+ + {/* 이미지 미리보기 */} + {invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && ( +
+ {invoice.attachments + .filter((f) => f.file_type?.startsWith("image/")) + .map((file) => ( +
+ {file.file_name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+
+

{file.file_name}

+ +
+
+
+ ))} +
+ )} + + {/* 기타 파일 목록 */} + {invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && ( +
+ {invoice.attachments + .filter((f) => !f.file_type?.startsWith("image/")) + .map((file) => ( +
+
+ {getFileIcon(file.file_type)} + {file.file_name} +
+ +
+ ))} +
+ )} +
+ )} +
+
+
+
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx new file mode 100644 index 00000000..08c3fb37 --- /dev/null +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -0,0 +1,706 @@ +"use client"; + +/** + * 세금계산서 작성/수정 폼 + * 파일 첨부 기능 포함 + */ + +import { useState, useEffect, useCallback } from "react"; +import { format } from "date-fns"; +import { + Plus, + Trash2, + Upload, + X, + FileText, + Image, + File, + Paperclip, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; + +import { + createTaxInvoice, + updateTaxInvoice, + getTaxInvoiceById, + TaxInvoice, + TaxInvoiceAttachment, + CreateTaxInvoiceDto, + CreateTaxInvoiceItemDto, +} from "@/lib/api/taxInvoice"; +import { apiClient } from "@/lib/api/client"; + +interface TaxInvoiceFormProps { + open: boolean; + onClose: () => void; + onSave: () => void; + invoice?: TaxInvoice | null; +} + +// 품목 초기값 +const emptyItem: CreateTaxInvoiceItemDto = { + item_date: format(new Date(), "yyyy-MM-dd"), + item_name: "", + item_spec: "", + quantity: 1, + unit_price: 0, + supply_amount: 0, + tax_amount: 0, + remarks: "", +}; + +export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) { + // 폼 상태 + const [formData, setFormData] = useState({ + invoice_type: "sales", + invoice_date: format(new Date(), "yyyy-MM-dd"), + supply_amount: 0, + tax_amount: 0, + total_amount: 0, + items: [{ ...emptyItem }], + }); + + // 첨부파일 상태 + const [attachments, setAttachments] = useState([]); + const [uploading, setUploading] = useState(false); + + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("basic"); + + // 수정 모드일 때 데이터 로드 + useEffect(() => { + if (invoice) { + loadInvoiceData(invoice.id); + } else { + // 새 세금계산서 + setFormData({ + invoice_type: "sales", + invoice_date: format(new Date(), "yyyy-MM-dd"), + supply_amount: 0, + tax_amount: 0, + total_amount: 0, + items: [{ ...emptyItem }], + }); + setAttachments([]); + } + }, [invoice]); + + // 세금계산서 데이터 로드 + const loadInvoiceData = async (id: string) => { + try { + const response = await getTaxInvoiceById(id); + if (response.success) { + const { invoice: inv, items } = response.data; + setFormData({ + invoice_type: inv.invoice_type, + invoice_date: inv.invoice_date?.split("T")[0] || "", + supplier_business_no: inv.supplier_business_no, + supplier_name: inv.supplier_name, + supplier_ceo_name: inv.supplier_ceo_name, + supplier_address: inv.supplier_address, + supplier_business_type: inv.supplier_business_type, + supplier_business_item: inv.supplier_business_item, + buyer_business_no: inv.buyer_business_no, + buyer_name: inv.buyer_name, + buyer_ceo_name: inv.buyer_ceo_name, + buyer_address: inv.buyer_address, + buyer_email: inv.buyer_email, + supply_amount: inv.supply_amount, + tax_amount: inv.tax_amount, + total_amount: inv.total_amount, + remarks: inv.remarks, + items: + items.length > 0 + ? items.map((item) => ({ + item_date: item.item_date?.split("T")[0] || "", + item_name: item.item_name, + item_spec: item.item_spec, + quantity: item.quantity, + unit_price: item.unit_price, + supply_amount: item.supply_amount, + tax_amount: item.tax_amount, + remarks: item.remarks, + })) + : [{ ...emptyItem }], + }); + setAttachments(inv.attachments || []); + } + } catch (error: any) { + toast.error("데이터 로드 실패", { description: error.message }); + } + }; + + // 필드 변경 + const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + // 품목 변경 + const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => { + setFormData((prev) => { + const items = [...(prev.items || [])]; + items[index] = { ...items[index], [field]: value }; + + // 공급가액 자동 계산 + if (field === "quantity" || field === "unit_price") { + const qty = field === "quantity" ? value : items[index].quantity; + const price = field === "unit_price" ? value : items[index].unit_price; + items[index].supply_amount = qty * price; + items[index].tax_amount = Math.round(items[index].supply_amount * 0.1); + } + + // 총액 재계산 + const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0); + const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0); + + return { + ...prev, + items, + supply_amount: totalSupply, + tax_amount: totalTax, + total_amount: totalSupply + totalTax, + }; + }); + }; + + // 품목 추가 + const handleAddItem = () => { + setFormData((prev) => ({ + ...prev, + items: [...(prev.items || []), { ...emptyItem }], + })); + }; + + // 품목 삭제 + const handleRemoveItem = (index: number) => { + setFormData((prev) => { + const items = (prev.items || []).filter((_, i) => i !== index); + const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0); + const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0); + + return { + ...prev, + items: items.length > 0 ? items : [{ ...emptyItem }], + supply_amount: totalSupply, + tax_amount: totalTax, + total_amount: totalSupply + totalTax, + }; + }); + }; + + // 파일 업로드 + const handleFileUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + setUploading(true); + try { + for (const file of Array.from(files)) { + const formDataUpload = new FormData(); + formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files" + formDataUpload.append("category", "tax-invoice"); + + const response = await apiClient.post("/files/upload", formDataUpload, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + if (response.data.success && response.data.files?.length > 0) { + const uploadedFile = response.data.files[0]; + const newAttachment: TaxInvoiceAttachment = { + id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + file_name: uploadedFile.realFileName || file.name, + file_path: uploadedFile.filePath, + file_size: uploadedFile.fileSize || file.size, + file_type: file.type, + uploaded_at: new Date().toISOString(), + uploaded_by: "", + }; + setAttachments((prev) => [...prev, newAttachment]); + toast.success(`'${file.name}' 업로드 완료`); + } + } + } catch (error: any) { + toast.error("파일 업로드 실패", { description: error.message }); + } finally { + setUploading(false); + // input 초기화 + e.target.value = ""; + } + }; + + // 첨부파일 삭제 + const handleRemoveAttachment = (id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }; + + // 파일 아이콘 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return ; + if (fileType.includes("pdf")) return ; + return ; + }; + + // 파일 크기 포맷 + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + // 저장 + const handleSave = async () => { + // 유효성 검사 + if (!formData.invoice_date) { + toast.error("작성일자를 입력해주세요."); + return; + } + + setSaving(true); + try { + const dataToSave = { + ...formData, + attachments, + }; + + let response; + if (invoice) { + response = await updateTaxInvoice(invoice.id, dataToSave); + } else { + response = await createTaxInvoice(dataToSave); + } + + if (response.success) { + toast.success(response.message || "저장되었습니다."); + onSave(); + } + } catch (error: any) { + toast.error("저장 실패", { description: error.message }); + } finally { + setSaving(false); + } + }; + + // 금액 포맷 + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + return ( + !o && onClose()}> + + + {invoice ? "세금계산서 수정" : "세금계산서 작성"} + 세금계산서 정보를 입력해주세요. + + + +
+ + + 기본정보 + 공급자 + 공급받는자 + + 첨부파일 + {attachments.length > 0 && ( + + {attachments.length} + + )} + + + + {/* 기본정보 탭 */} + +
+
+ + +
+
+ + handleChange("invoice_date", e.target.value)} + className="h-9" + /> +
+
+ + handleChange("remarks", e.target.value)} + className="h-9" + placeholder="비고 입력" + /> +
+
+ + {/* 품목 테이블 */} + + +
+ 품목 내역 + +
+
+ + + + + 일자 + 품목명 + 규격 + 수량 + 단가 + 공급가액 + 세액 + + + + + {(formData.items || []).map((item, index) => ( + + + + handleItemChange(index, "item_date", e.target.value) + } + className="h-8 text-xs" + /> + + + + handleItemChange(index, "item_name", e.target.value) + } + className="h-8 text-xs" + placeholder="품목명" + /> + + + + handleItemChange(index, "item_spec", e.target.value) + } + className="h-8 text-xs" + placeholder="규격" + /> + + + + handleItemChange(index, "quantity", parseFloat(e.target.value) || 0) + } + className="h-8 text-right text-xs" + min={0} + /> + + + + handleItemChange( + index, + "unit_price", + parseFloat(e.target.value) || 0 + ) + } + className="h-8 text-right text-xs" + min={0} + /> + + + {formatAmount(item.supply_amount || 0)} + + + {formatAmount(item.tax_amount || 0)} + + + + + + ))} + +
+
+
+ + {/* 합계 */} +
+
+
+ 공급가액 + {formatAmount(formData.supply_amount || 0)}원 +
+
+ 세액 + {formatAmount(formData.tax_amount || 0)}원 +
+
+ 합계 + + {formatAmount(formData.total_amount || 0)}원 + +
+
+
+
+ + {/* 공급자 탭 */} + +
+
+ + handleChange("supplier_business_no", e.target.value)} + className="h-9" + placeholder="000-00-00000" + /> +
+
+ + handleChange("supplier_name", e.target.value)} + className="h-9" + placeholder="상호명" + /> +
+
+ + handleChange("supplier_ceo_name", e.target.value)} + className="h-9" + placeholder="대표자명" + /> +
+
+ + handleChange("supplier_business_type", e.target.value)} + className="h-9" + placeholder="업태" + /> +
+
+ + handleChange("supplier_business_item", e.target.value)} + className="h-9" + placeholder="종목" + /> +
+
+ + handleChange("supplier_address", e.target.value)} + className="h-9" + placeholder="주소" + /> +
+
+
+ + {/* 공급받는자 탭 */} + +
+
+ + handleChange("buyer_business_no", e.target.value)} + className="h-9" + placeholder="000-00-00000" + /> +
+
+ + handleChange("buyer_name", e.target.value)} + className="h-9" + placeholder="상호명" + /> +
+
+ + handleChange("buyer_ceo_name", e.target.value)} + className="h-9" + placeholder="대표자명" + /> +
+
+ + handleChange("buyer_email", e.target.value)} + className="h-9" + placeholder="email@example.com" + /> +
+
+ + handleChange("buyer_address", e.target.value)} + className="h-9" + placeholder="주소" + /> +
+
+
+ + {/* 첨부파일 탭 */} + + {/* 업로드 영역 */} +
+ + +
+ + {/* 첨부파일 목록 */} + {attachments.length > 0 && ( +
+ +
+ {attachments.map((file) => ( +
+
+ {getFileIcon(file.file_type)} +
+

{file.file_name}

+

+ {formatFileSize(file.file_size)} +

+
+
+ +
+ ))} +
+
+ )} + + {attachments.length === 0 && ( +
+ + 첨부된 파일이 없습니다. +
+ )} +
+
+
+
+ + + + + +
+
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceList.tsx b/frontend/components/tax-invoice/TaxInvoiceList.tsx new file mode 100644 index 00000000..a4c59822 --- /dev/null +++ b/frontend/components/tax-invoice/TaxInvoiceList.tsx @@ -0,0 +1,818 @@ +"use client"; + +/** + * 세금계산서 목록 컴포넌트 + * 세금계산서 목록 조회, 검색, 필터링 기능 + */ + +import { useState, useEffect, useCallback } from "react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Plus, + Search, + Filter, + FileText, + Eye, + Edit, + Trash2, + Send, + CheckCircle, + XCircle, + Clock, + RefreshCw, + Paperclip, + Image, + File, + ArrowUpDown, + ArrowUp, + ArrowDown, + X, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { toast } from "sonner"; + +import { + getTaxInvoiceList, + deleteTaxInvoice, + issueTaxInvoice, + cancelTaxInvoice, + TaxInvoice, + TaxInvoiceListParams, +} from "@/lib/api/taxInvoice"; +import { TaxInvoiceForm } from "./TaxInvoiceForm"; +import { TaxInvoiceDetail } from "./TaxInvoiceDetail"; + +// 상태 뱃지 색상 +const statusBadgeVariant: Record = { + draft: "outline", + issued: "default", + sent: "secondary", + cancelled: "destructive", +}; + +// 상태 라벨 +const statusLabels: Record = { + draft: "임시저장", + issued: "발행완료", + sent: "전송완료", + cancelled: "취소됨", +}; + +// 유형 라벨 +const typeLabels: Record = { + sales: "매출", + purchase: "매입", +}; + +// 컬럼 정의 +interface ColumnDef { + key: string; + label: string; + sortable?: boolean; + filterable?: boolean; + filterType?: "text" | "select"; + filterOptions?: { value: string; label: string }[]; + width?: string; + align?: "left" | "center" | "right"; +} + +const columns: ColumnDef[] = [ + { key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" }, + { key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select", + filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "80px" }, + { key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select", + filterOptions: [ + { value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" }, + { value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" } + ], width: "100px" }, + { key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" }, + { key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" }, + { key: "attachments", label: "첨부", sortable: false, filterable: false, width: "60px", align: "center" }, + { key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" }, + { key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" }, + { key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" }, +]; + +export function TaxInvoiceList() { + // 상태 + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + page: 1, + pageSize: 20, + total: 0, + totalPages: 0, + }); + + // 필터 상태 + const [filters, setFilters] = useState({ + page: 1, + pageSize: 20, + }); + const [searchText, setSearchText] = useState(""); + + // 정렬 상태 + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + + // 컬럼별 필터 상태 + const [columnFilters, setColumnFilters] = useState>({}); + const [activeFilterColumn, setActiveFilterColumn] = useState(null); + + // 모달 상태 + const [showForm, setShowForm] = useState(false); + const [showDetail, setShowDetail] = useState(false); + const [selectedInvoice, setSelectedInvoice] = useState(null); + const [editMode, setEditMode] = useState(false); + + // 확인 다이얼로그 상태 + const [confirmDialog, setConfirmDialog] = useState<{ + open: boolean; + type: "delete" | "issue" | "cancel"; + invoice: TaxInvoice | null; + }>({ + open: false, + type: "delete", + invoice: null, + }); + + // 데이터 로드 + const loadData = useCallback(async () => { + setLoading(true); + try { + // 컬럼 필터를 API 파라미터에 추가 + const apiFilters: TaxInvoiceListParams = { + ...filters, + invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined, + invoice_status: columnFilters.invoice_status, + search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined, + }; + + const response = await getTaxInvoiceList(apiFilters); + if (response.success) { + let data = response.data; + + // 클라이언트 사이드 정렬 적용 + if (sortConfig) { + data = [...data].sort((a, b) => { + const aVal = a[sortConfig.key as keyof TaxInvoice]; + const bVal = b[sortConfig.key as keyof TaxInvoice]; + + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + // 숫자 비교 + if (typeof aVal === "number" && typeof bVal === "number") { + return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal; + } + + // 문자열 비교 + const strA = String(aVal).toLowerCase(); + const strB = String(bVal).toLowerCase(); + if (sortConfig.direction === "asc") { + return strA.localeCompare(strB, "ko"); + } + return strB.localeCompare(strA, "ko"); + }); + } + + // 클라이언트 사이드 필터 적용 (날짜 필터) + if (columnFilters.invoice_date) { + data = data.filter((item) => + item.invoice_date?.includes(columnFilters.invoice_date) + ); + } + + setInvoices(data); + setPagination(response.pagination); + } + } catch (error: any) { + toast.error("데이터 로드 실패", { description: error.message }); + } finally { + setLoading(false); + } + }, [filters, sortConfig, columnFilters, searchText]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 정렬 핸들러 + const handleSort = (columnKey: string) => { + setSortConfig((prev) => { + if (prev?.key === columnKey) { + // 같은 컬럼 클릭: asc -> desc -> null 순환 + if (prev.direction === "asc") return { key: columnKey, direction: "desc" }; + return null; + } + // 새 컬럼: asc로 시작 + return { key: columnKey, direction: "asc" }; + }); + }; + + // 컬럼 필터 핸들러 + const handleColumnFilter = (columnKey: string, value: string) => { + setColumnFilters((prev) => { + if (!value) { + const { [columnKey]: _, ...rest } = prev; + return rest; + } + return { ...prev, [columnKey]: value }; + }); + setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로 + }; + + // 필터 초기화 + const clearColumnFilter = (columnKey: string) => { + setColumnFilters((prev) => { + const { [columnKey]: _, ...rest } = prev; + return rest; + }); + setActiveFilterColumn(null); + }; + + // 모든 필터 초기화 + const clearAllFilters = () => { + setColumnFilters({}); + setSortConfig(null); + setSearchText(""); + setFilters({ page: 1, pageSize: 20 }); + }; + + // 정렬 아이콘 렌더링 + const renderSortIcon = (columnKey: string) => { + if (sortConfig?.key !== columnKey) { + return ; + } + return sortConfig.direction === "asc" + ? + : ; + }; + + // 검색 + const handleSearch = () => { + setFilters((prev) => ({ ...prev, search: searchText, page: 1 })); + }; + + // 필터 변경 + const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => { + setFilters((prev) => ({ + ...prev, + [key]: value === "all" ? undefined : value, + page: 1, + })); + }; + + // 새 세금계산서 + const handleNew = () => { + setSelectedInvoice(null); + setEditMode(false); + setShowForm(true); + }; + + // 상세 보기 + const handleView = (invoice: TaxInvoice) => { + setSelectedInvoice(invoice); + setShowDetail(true); + }; + + // 수정 + const handleEdit = (invoice: TaxInvoice) => { + if (invoice.invoice_status !== "draft") { + toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다."); + return; + } + setSelectedInvoice(invoice); + setEditMode(true); + setShowForm(true); + }; + + // 삭제 확인 + const handleDeleteConfirm = (invoice: TaxInvoice) => { + if (invoice.invoice_status !== "draft") { + toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다."); + return; + } + setConfirmDialog({ open: true, type: "delete", invoice }); + }; + + // 발행 확인 + const handleIssueConfirm = (invoice: TaxInvoice) => { + if (invoice.invoice_status !== "draft") { + toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다."); + return; + } + setConfirmDialog({ open: true, type: "issue", invoice }); + }; + + // 취소 확인 + const handleCancelConfirm = (invoice: TaxInvoice) => { + if (!["draft", "issued"].includes(invoice.invoice_status)) { + toast.warning("취소할 수 없는 상태입니다."); + return; + } + setConfirmDialog({ open: true, type: "cancel", invoice }); + }; + + // 확인 다이얼로그 실행 + const handleConfirmAction = async () => { + const { type, invoice } = confirmDialog; + if (!invoice) return; + + try { + if (type === "delete") { + const response = await deleteTaxInvoice(invoice.id); + if (response.success) { + toast.success("세금계산서가 삭제되었습니다."); + loadData(); + } + } else if (type === "issue") { + const response = await issueTaxInvoice(invoice.id); + if (response.success) { + toast.success("세금계산서가 발행되었습니다."); + loadData(); + } + } else if (type === "cancel") { + const response = await cancelTaxInvoice(invoice.id); + if (response.success) { + toast.success("세금계산서가 취소되었습니다."); + loadData(); + } + } + } catch (error: any) { + toast.error("작업 실패", { description: error.message }); + } finally { + setConfirmDialog({ open: false, type: "delete", invoice: null }); + } + }; + + // 폼 저장 완료 + const handleFormSave = () => { + setShowForm(false); + setSelectedInvoice(null); + loadData(); + }; + + // 금액 포맷 + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + // 날짜 포맷 + const formatDate = (dateString: string) => { + try { + return format(new Date(dateString), "yyyy-MM-dd", { locale: ko }); + } catch { + return dateString; + } + }; + + return ( +
+ {/* 헤더 */} +
+

세금계산서 관리

+ +
+ + {/* 필터 영역 */} + + +
+ {/* 검색 */} +
+ +
+ setSearchText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + className="h-9" + /> + +
+
+ + {/* 유형 필터 */} +
+ + +
+ + {/* 상태 필터 */} +
+ + +
+ + {/* 새로고침 */} + + + {/* 필터 초기화 */} + {(Object.keys(columnFilters).length > 0 || sortConfig) && ( + + )} +
+ + {/* 활성 필터 표시 */} + {Object.keys(columnFilters).length > 0 && ( +
+ {Object.entries(columnFilters).map(([key, value]) => { + const column = columns.find((c) => c.key === key); + let displayValue = value; + if (column?.filterOptions) { + displayValue = column.filterOptions.find((o) => o.value === value)?.label || value; + } + return ( + + {column?.label}: {displayValue} + + + ); + })} +
+ )} +
+
+ + {/* 테이블 */} + + + + + + {columns.map((column) => ( + +
+ {/* 컬럼 필터 (filterable인 경우) */} + {column.filterable && ( + setActiveFilterColumn(open ? column.key : null)} + > + + + + +
+
{column.label} 필터
+ {column.filterType === "select" ? ( + + ) : ( + handleColumnFilter(column.key, e.target.value)} + onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)} + className="h-8 text-xs" + autoFocus + /> + )} + {columnFilters[column.key] && ( + + )} +
+
+
+ )} + + {/* 컬럼 라벨 + 정렬 */} + {column.sortable ? ( + + ) : ( + {column.label} + )} +
+
+ ))} + 작업 +
+
+ + {loading ? ( + + + 로딩 중... + + + ) : invoices.length === 0 ? ( + + + + 세금계산서가 없습니다. + + + ) : ( + invoices.map((invoice) => ( + + {invoice.invoice_number} + + + {typeLabels[invoice.invoice_type]} + + + + + {statusLabels[invoice.invoice_status]} + + + {formatDate(invoice.invoice_date)} + + {invoice.buyer_name || "-"} + + + {invoice.attachments && invoice.attachments.length > 0 ? ( +
+ + + {invoice.attachments.length} + +
+ ) : ( + - + )} +
+ + {formatAmount(invoice.supply_amount)} + + + {formatAmount(invoice.tax_amount)} + + + {formatAmount(invoice.total_amount)} + + +
+ + {invoice.invoice_status === "draft" && ( + <> + + + + + )} + {invoice.invoice_status === "issued" && ( + + )} +
+
+
+ )) + )} +
+
+
+
+ + {/* 페이지네이션 */} + {pagination.totalPages > 1 && ( +
+
+ 총 {pagination.total}건 중 {(pagination.page - 1) * pagination.pageSize + 1}- + {Math.min(pagination.page * pagination.pageSize, pagination.total)}건 +
+
+ + +
+
+ )} + + {/* 세금계산서 작성/수정 폼 */} + {showForm && ( + setShowForm(false)} + onSave={handleFormSave} + invoice={editMode ? selectedInvoice : null} + /> + )} + + {/* 세금계산서 상세 */} + {showDetail && selectedInvoice && ( + setShowDetail(false)} + invoiceId={selectedInvoice.id} + /> + )} + + {/* 확인 다이얼로그 */} + !open && setConfirmDialog({ ...confirmDialog, open: false })} + > + + + + {confirmDialog.type === "delete" && "세금계산서 삭제"} + {confirmDialog.type === "issue" && "세금계산서 발행"} + {confirmDialog.type === "cancel" && "세금계산서 취소"} + + + {confirmDialog.type === "delete" && + "이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."} + {confirmDialog.type === "issue" && + "이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."} + {confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"} + + + + + + + + +
+ ); +} + diff --git a/frontend/components/tax-invoice/index.ts b/frontend/components/tax-invoice/index.ts new file mode 100644 index 00000000..149e2812 --- /dev/null +++ b/frontend/components/tax-invoice/index.ts @@ -0,0 +1,4 @@ +export { TaxInvoiceList } from "./TaxInvoiceList"; +export { TaxInvoiceForm } from "./TaxInvoiceForm"; +export { TaxInvoiceDetail } from "./TaxInvoiceDetail"; + diff --git a/frontend/lib/api/taxInvoice.ts b/frontend/lib/api/taxInvoice.ts new file mode 100644 index 00000000..be41f24c --- /dev/null +++ b/frontend/lib/api/taxInvoice.ts @@ -0,0 +1,229 @@ +/** + * 세금계산서 API 클라이언트 + */ + +import { apiClient } from "./client"; + +// 세금계산서 타입 +export interface TaxInvoice { + id: string; + company_code: string; + invoice_number: string; + invoice_type: "sales" | "purchase"; + invoice_status: "draft" | "issued" | "sent" | "cancelled"; + supplier_business_no: string; + supplier_name: string; + supplier_ceo_name: string; + supplier_address: string; + supplier_business_type: string; + supplier_business_item: string; + buyer_business_no: string; + buyer_name: string; + buyer_ceo_name: string; + buyer_address: string; + buyer_email: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + issue_date: string | null; + remarks: string; + order_id: string | null; + customer_id: string | null; + attachments: TaxInvoiceAttachment[] | null; + created_date: string; + updated_date: string; + writer: string; +} + +// 첨부파일 타입 +export interface TaxInvoiceAttachment { + id: string; + file_name: string; + file_path: string; + file_size: number; + file_type: string; + uploaded_at: string; + uploaded_by: string; +} + +// 세금계산서 품목 타입 +export interface TaxInvoiceItem { + id: string; + tax_invoice_id: string; + company_code: string; + item_seq: number; + item_date: string; + item_name: string; + item_spec: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks: string; +} + +// 생성 DTO +export interface CreateTaxInvoiceDto { + invoice_type: "sales" | "purchase"; + supplier_business_no?: string; + supplier_name?: string; + supplier_ceo_name?: string; + supplier_address?: string; + supplier_business_type?: string; + supplier_business_item?: string; + buyer_business_no?: string; + buyer_name?: string; + buyer_ceo_name?: string; + buyer_address?: string; + buyer_email?: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + remarks?: string; + order_id?: string; + customer_id?: string; + items?: CreateTaxInvoiceItemDto[]; + attachments?: TaxInvoiceAttachment[]; +} + +// 품목 생성 DTO +export interface CreateTaxInvoiceItemDto { + item_date?: string; + item_name: string; + item_spec?: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks?: string; +} + +// 목록 조회 파라미터 +export interface TaxInvoiceListParams { + page?: number; + pageSize?: number; + invoice_type?: "sales" | "purchase"; + invoice_status?: string; + start_date?: string; + end_date?: string; + search?: string; + buyer_name?: string; +} + +// 목록 응답 +export interface TaxInvoiceListResponse { + success: boolean; + data: TaxInvoice[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; +} + +// 상세 응답 +export interface TaxInvoiceDetailResponse { + success: boolean; + data: { + invoice: TaxInvoice; + items: TaxInvoiceItem[]; + }; +} + +// 월별 통계 응답 +export interface TaxInvoiceMonthlyStatsResponse { + success: boolean; + data: { + sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + }; + period: { year: number; month: number }; +} + +/** + * 세금계산서 목록 조회 + */ +export async function getTaxInvoiceList( + params?: TaxInvoiceListParams +): Promise { + const response = await apiClient.get("/tax-invoice", { params }); + return response.data; +} + +/** + * 세금계산서 상세 조회 + */ +export async function getTaxInvoiceById(id: string): Promise { + const response = await apiClient.get(`/tax-invoice/${id}`); + return response.data; +} + +/** + * 세금계산서 생성 + */ +export async function createTaxInvoice( + data: CreateTaxInvoiceDto +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.post("/tax-invoice", data); + return response.data; +} + +/** + * 세금계산서 수정 + */ +export async function updateTaxInvoice( + id: string, + data: Partial +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.put(`/tax-invoice/${id}`, data); + return response.data; +} + +/** + * 세금계산서 삭제 + */ +export async function deleteTaxInvoice( + id: string +): Promise<{ success: boolean; message: string }> { + const response = await apiClient.delete(`/tax-invoice/${id}`); + return response.data; +} + +/** + * 세금계산서 발행 + */ +export async function issueTaxInvoice( + id: string +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.post(`/tax-invoice/${id}/issue`); + return response.data; +} + +/** + * 세금계산서 취소 + */ +export async function cancelTaxInvoice( + id: string, + reason?: string +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.post(`/tax-invoice/${id}/cancel`, { reason }); + return response.data; +} + +/** + * 월별 통계 조회 + */ +export async function getTaxInvoiceMonthlyStats( + year?: number, + month?: number +): Promise { + const params: Record = {}; + if (year) params.year = year; + if (month) params.month = month; + const response = await apiClient.get("/tax-invoice/stats/monthly", { params }); + return response.data; +} + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index ff9d9240..f3e0552d 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 // 🆕 범용 폼 모달 컴포넌트 import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원 +// 🆕 세금계산서 관리 컴포넌트 +import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소 + // 🆕 렉 구조 설정 컴포넌트 import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성 diff --git a/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx new file mode 100644 index 00000000..43bad4f2 --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx @@ -0,0 +1,48 @@ +"use client"; + +/** + * 세금계산서 목록 컴포넌트 (레지스트리용 래퍼) + */ + +import React from "react"; +import { TaxInvoiceList } from "@/components/tax-invoice"; +import { TaxInvoiceListConfig } from "./types"; + +interface TaxInvoiceListComponentProps { + config?: TaxInvoiceListConfig; + componentId?: string; + isEditMode?: boolean; +} + +export function TaxInvoiceListComponent({ + config, + componentId, + isEditMode, +}: TaxInvoiceListComponentProps) { + // 편집 모드에서는 플레이스홀더 표시 + if (isEditMode) { + return ( +
+
+
📄
+

세금계산서 목록

+

+ {config?.title || "세금계산서 관리"} +

+
+
+ ); + } + + return ( +
+ +
+ ); +} + +// 래퍼 컴포넌트 (레지스트리 호환용) +export function TaxInvoiceListWrapper(props: any) { + return ; +} + diff --git a/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx new file mode 100644 index 00000000..fc3fd62b --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx @@ -0,0 +1,166 @@ +"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 { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types"; + +interface TaxInvoiceListConfigPanelProps { + config: TaxInvoiceListConfig; + onChange: (config: TaxInvoiceListConfig) => void; +} + +export function TaxInvoiceListConfigPanel({ + config, + onChange, +}: TaxInvoiceListConfigPanelProps) { + const currentConfig = { ...defaultTaxInvoiceListConfig, ...config }; + + const handleChange = (key: keyof TaxInvoiceListConfig, value: any) => { + onChange({ ...currentConfig, [key]: value }); + }; + + return ( +
+ {/* 기본 설정 */} +
+

기본 설정

+ +
+ + handleChange("title", e.target.value)} + placeholder="세금계산서 관리" + className="h-8 text-xs" + /> +
+ +
+ + handleChange("showHeader", checked)} + /> +
+
+ + {/* 기본 필터 */} +
+

기본 필터

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* 권한 설정 */} +
+

권한 설정

+ +
+ + handleChange("canCreate", checked)} + /> +
+ +
+ + handleChange("canEdit", checked)} + /> +
+ +
+ + handleChange("canDelete", checked)} + /> +
+ +
+ + handleChange("canIssue", checked)} + /> +
+ +
+ + handleChange("canCancel", checked)} + /> +
+
+
+ ); +} + diff --git a/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx new file mode 100644 index 00000000..7f4b2806 --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { TaxInvoiceListDefinition } from "./index"; +import { TaxInvoiceListComponent } from "./TaxInvoiceListComponent"; + +/** + * 세금계산서 목록 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class TaxInvoiceListRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = TaxInvoiceListDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +TaxInvoiceListRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + TaxInvoiceListRenderer.registerSelf(); + } catch (error) { + console.error("TaxInvoiceList 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/tax-invoice-list/index.ts b/frontend/lib/registry/components/tax-invoice-list/index.ts new file mode 100644 index 00000000..b7a589cc --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/index.ts @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent"; +import { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel"; +import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types"; + +/** + * 세금계산서 목록 컴포넌트 정의 + * 세금계산서 CRUD, 발행, 취소 기능을 제공하는 컴포넌트 + */ +export const TaxInvoiceListDefinition = createComponentDefinition({ + id: "tax-invoice-list", + name: "세금계산서 목록", + nameEng: "Tax Invoice List", + description: "세금계산서 목록 조회, 작성, 발행, 취소 기능을 제공하는 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: TaxInvoiceListWrapper, + defaultConfig: defaultTaxInvoiceListConfig, + defaultSize: { width: 1200, height: 700 }, + configPanel: TaxInvoiceListConfigPanel, + icon: "FileText", + tags: ["세금계산서", "매출", "매입", "발행", "인보이스"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +// 타입 내보내기 +export type { TaxInvoiceListConfig } from "./types"; +export { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent"; +export { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel"; +export { TaxInvoiceListRenderer } from "./TaxInvoiceListRenderer"; + diff --git a/frontend/lib/registry/components/tax-invoice-list/types.ts b/frontend/lib/registry/components/tax-invoice-list/types.ts new file mode 100644 index 00000000..57ebb948 --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/types.ts @@ -0,0 +1,41 @@ +/** + * 세금계산서 목록 컴포넌트 타입 정의 + */ + +export interface TaxInvoiceListConfig { + // 기본 설정 + title?: string; + showHeader?: boolean; + + // 필터 설정 + defaultInvoiceType?: "all" | "sales" | "purchase"; + defaultStatus?: "all" | "draft" | "issued" | "sent" | "cancelled"; + + // 페이지네이션 + pageSize?: number; + + // 권한 설정 + canCreate?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canIssue?: boolean; + canCancel?: boolean; + + // 스타일 + height?: string | number; +} + +export const defaultTaxInvoiceListConfig: TaxInvoiceListConfig = { + title: "세금계산서 관리", + showHeader: true, + defaultInvoiceType: "all", + defaultStatus: "all", + pageSize: 20, + canCreate: true, + canEdit: true, + canDelete: true, + canIssue: true, + canCancel: true, + height: "auto", +}; + From 66ef1172ee2ab63f92701a3fcf07198ebc03e265 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 12:59:03 +0900 Subject: [PATCH 19/24] =?UTF-8?q?feat(UniversalFormModal):=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=ED=95=84=EB=93=9C=20=EA=B7=B8=EB=A3=B9=20=EA=B8=B0?= =?UTF-8?q?=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 - LinkedFieldGroup, LinkedFieldMapping 타입 정의 - 소스 테이블 데이터 캐싱 및 드롭다운 렌더링 - 선택 시 여러 컬럼에 자동 값 매핑 처리 - 설정 패널에 연동 필드 그룹 관리 UI 추가 - 일반 섹션/반복 섹션 모두 지원 --- .../UniversalFormModalComponent.tsx | 224 ++++++++++++ .../UniversalFormModalConfigPanel.tsx | 323 ++++++++++++++++++ .../components/universal-form-modal/config.ts | 26 ++ .../components/universal-form-modal/types.ts | 31 ++ 4 files changed, 604 insertions(+) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 85133424..65e079ae 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -33,6 +33,7 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, + LinkedFieldGroup, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -100,6 +101,11 @@ export function UniversalFormModalComponent({ [key: string]: { value: string; label: string }[]; }>({}); + // 연동 필드 그룹 데이터 캐시 (테이블별 데이터) + const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{ + [tableKey: string]: Record[]; + }>({}); + // 로딩 상태 const [saving, setSaving] = useState(false); @@ -342,6 +348,125 @@ export function UniversalFormModalComponent({ [selectOptionsCache], ); + // 연동 필드 그룹 데이터 로드 + const loadLinkedFieldData = useCallback( + async (sourceTable: string): Promise[]> => { + // 캐시 확인 - 이미 배열로 캐시되어 있으면 반환 + if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) { + return linkedFieldDataCache[sourceTable]; + } + + let data: Record[] = []; + + try { + console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`); + // 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용) + const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, + size: 1000, + autoFilter: true, // 현재 회사 기준 자동 필터링 + }); + + console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); + + if (response.data?.success) { + // data가 배열인지 확인 + const responseData = response.data?.data; + if (Array.isArray(responseData)) { + data = responseData; + } else if (responseData?.rows && Array.isArray(responseData.rows)) { + // { rows: [...], total: ... } 형태일 수 있음 + data = responseData.rows; + } + console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); + } + + // 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지) + setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data })); + } catch (error) { + console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error); + // 실패해도 빈 배열로 캐시하여 무한 요청 방지 + setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] })); + } + + return data; + }, + [linkedFieldDataCache], + ); + + // 연동 필드 그룹 선택 시 매핑된 필드에 값 설정 + const handleLinkedFieldSelect = useCallback( + ( + group: LinkedFieldGroup, + selectedValue: string, + sectionId: string, + repeatItemId?: string + ) => { + // 캐시에서 데이터 찾기 + const sourceData = linkedFieldDataCache[group.sourceTable] || []; + const selectedRow = sourceData.find( + (row) => String(row[group.valueColumn]) === selectedValue + ); + + if (!selectedRow) { + console.warn("선택된 항목을 찾을 수 없습니다:", selectedValue); + return; + } + + // 매핑된 필드에 값 설정 + if (repeatItemId) { + // 반복 섹션 내 아이템 업데이트 + setRepeatSections((prev) => { + const sectionItems = prev[sectionId] || []; + const updatedItems = sectionItems.map((item) => { + if (item._id === repeatItemId) { + const updatedItem = { ...item }; + for (const mapping of group.mappings) { + updatedItem[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; + } + return updatedItem; + } + return item; + }); + return { ...prev, [sectionId]: updatedItems }; + }); + } else { + // 일반 섹션 필드 업데이트 + setFormData((prev) => { + const newData = { ...prev }; + for (const mapping of group.mappings) { + newData[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; + } + if (onChange) { + setTimeout(() => onChange(newData), 0); + } + return newData; + }); + } + }, + [linkedFieldDataCache, onChange], + ); + + // 연동 필드 그룹 표시 텍스트 생성 + const getLinkedFieldDisplayText = useCallback( + (group: LinkedFieldGroup, row: Record): string => { + const code = row[group.valueColumn] || ""; + const name = row[group.displayColumn] || ""; + + switch (group.displayFormat) { + case "name_only": + return name; + case "code_name": + return `${code} - ${name}`; + case "name_code": + return `${name} (${code})`; + default: + return name; + } + }, + [], + ); + // 필수 필드 검증 const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const missingFields: string[] = []; @@ -729,6 +854,64 @@ export function UniversalFormModalComponent({ })(); }; + // 연동 필드 그룹 드롭다운 렌더링 + const renderLinkedFieldGroup = ( + group: LinkedFieldGroup, + sectionId: string, + repeatItemId?: string, + currentValue?: string, + sectionColumns: number = 2, + ) => { + const fieldKey = `linked_${group.id}_${repeatItemId || "main"}`; + const cachedData = linkedFieldDataCache[group.sourceTable]; + // 배열인지 확인하고, 아니면 빈 배열 사용 + const sourceData = Array.isArray(cachedData) ? cachedData : []; + const defaultSpan = Math.floor(12 / sectionColumns); + const actualGridSpan = sectionColumns === 1 ? 12 : group.gridSpan || defaultSpan; + + // 데이터 로드 (아직 없으면, 그리고 캐시에 없을 때만) + if (!cachedData && group.sourceTable) { + loadLinkedFieldData(group.sourceTable); + } + + return ( +
+ + +
+ ); + }; + // 섹션의 열 수에 따른 기본 gridSpan 계산 const getDefaultGridSpan = (sectionColumns: number = 2): number => { // 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3 @@ -806,6 +989,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -815,6 +999,18 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 */} + {(section.linkedFieldGroups || []).map((group) => { + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + undefined, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
@@ -827,6 +1023,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -836,6 +1033,19 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 */} + {(section.linkedFieldGroups || []).map((group) => { + // 매핑된 첫 번째 타겟 컬럼의 현재 값을 찾아서 선택 상태 표시 + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + undefined, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
@@ -885,6 +1095,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -894,6 +1105,19 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 (반복 섹션 내) */} + {(section.linkedFieldGroups || []).map((group) => { + // 반복 섹션 아이템 내의 매핑된 첫 번째 타겟 컬럼 값 + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? item[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + item._id, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
))} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 90c9d64b..dc35a77e 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -37,17 +37,23 @@ import { UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, + LinkedFieldGroup, + LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, SELECT_OPTION_TYPE_OPTIONS, + LINKED_FIELD_DISPLAY_FORMAT_OPTIONS, } from "./types"; import { defaultFieldConfig, defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, + defaultLinkedFieldGroupConfig, + defaultLinkedFieldMappingConfig, generateSectionId, generateFieldId, + generateLinkedFieldGroupId, } from "./config"; // 도움말 텍스트 컴포넌트 @@ -87,6 +93,24 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.tableName]); + // 연동 필드 그룹의 소스 테이블 컬럼 로드 + useEffect(() => { + const allSourceTables = new Set(); + config.sections.forEach((section) => { + (section.linkedFieldGroups || []).forEach((group) => { + if (group.sourceTable) { + allSourceTables.add(group.sourceTable); + } + }); + }); + allSourceTables.forEach((tableName) => { + if (!tableColumns[tableName]) { + loadTableColumns(tableName); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections]); + const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); @@ -842,6 +866,305 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )}
+ {/* 연동 필드 그룹 설정 */} +
+
+ 연동 필드 그룹 + +
+

+ 부서코드/부서명 연동 저장 +

+ + {(selectedSection.linkedFieldGroups || []).length > 0 && ( +
+ {(selectedSection.linkedFieldGroups || []).map((group, groupIndex) => ( +
+
+ + #{groupIndex + 1} + + +
+ + {/* 라벨 */} +
+ + { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, label: e.target.value } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + placeholder="예: 겸직부서" + className="h-5 text-[9px] mt-0.5" + /> +
+ + {/* 소스 테이블 */} +
+ + +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 표시 컬럼 / 값 컬럼 */} +
+
+ + +
+
+ + +
+
+ + {/* 필드 매핑 */} +
+
+ + +
+ + {(group.mappings || []).map((mapping, mappingIndex) => ( +
+ + -> + + +
+ ))} +
+ + {/* 기타 옵션 */} +
+
+ { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, required: !!checked } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + className="h-3 w-3" + /> + +
+
+ + { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, gridSpan: parseInt(e.target.value) || 6 } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + className="h-4 w-8 text-[8px] px-1" + /> +
+
+
+ ))} +
+ )} +
+ {/* 필드 목록 */} diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 9da7b46c..5383512b 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -90,6 +90,27 @@ export const defaultSectionConfig = { itemTitle: "항목 {index}", confirmRemove: false, }, + linkedFieldGroups: [], +}; + +// 기본 연동 필드 그룹 설정 +export const defaultLinkedFieldGroupConfig = { + id: "", + label: "연동 필드", + sourceTable: "dept_info", + displayFormat: "code_name" as const, + displayColumn: "dept_name", + valueColumn: "dept_code", + mappings: [], + required: false, + placeholder: "선택하세요", + gridSpan: 6, +}; + +// 기본 연동 필드 매핑 설정 +export const defaultLinkedFieldMappingConfig = { + sourceColumn: "", + targetColumn: "", }; // 기본 채번규칙 설정 @@ -136,3 +157,8 @@ export const generateSectionId = (): string => { export const generateFieldId = (): string => { return generateUniqueId("field"); }; + +// 유틸리티: 연동 필드 그룹 ID 생성 +export const generateLinkedFieldGroupId = (): string => { + return generateUniqueId("linked"); +}; diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index e8d2ffd6..11ccfd25 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -96,6 +96,27 @@ export interface FormFieldConfig { }; } +// 연동 필드 매핑 설정 +export interface LinkedFieldMapping { + sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code") + targetColumn: string; // 저장할 컬럼 (예: "position_code") +} + +// 연동 필드 그룹 설정 (섹션 레벨) +// 하나의 드롭다운에서 선택 시 여러 컬럼에 자동 저장 +export interface LinkedFieldGroup { + id: string; + label: string; // 드롭다운 라벨 (예: "겸직부서") + sourceTable: string; // 소스 테이블 (예: "dept_info") + displayFormat: "name_only" | "code_name" | "name_code"; // 표시 형식 + displayColumn: string; // 표시할 컬럼 (예: "dept_name") + valueColumn: string; // 값으로 사용할 컬럼 (예: "dept_code") + mappings: LinkedFieldMapping[]; // 필드 매핑 목록 + required?: boolean; // 필수 여부 + placeholder?: string; // 플레이스홀더 + gridSpan?: number; // 그리드 스팬 (1-12) +} + // 반복 섹션 설정 export interface RepeatSectionConfig { minItems?: number; // 최소 항목 수 (기본: 0) @@ -119,6 +140,9 @@ export interface FormSectionConfig { repeatable?: boolean; repeatConfig?: RepeatSectionConfig; + // 연동 필드 그룹 (부서코드/부서명 등 연동 저장) + linkedFieldGroups?: LinkedFieldGroup[]; + // 섹션 레이아웃 columns?: number; // 필드 배치 컬럼 수 (기본: 2) gap?: string; // 필드 간 간격 @@ -257,3 +281,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [ { value: "table", label: "테이블 참조" }, { value: "code", label: "공통코드" }, ] as const; + +// 연동 필드 표시 형식 옵션 +export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [ + { value: "name_only", label: "이름만 (예: 영업부)" }, + { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, + { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, +] as const; From 94986d4af01b4c666ebaad688c1d552076e6d8d2 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 17:25:12 +0900 Subject: [PATCH 20/24] =?UTF-8?q?refactor(UniversalFormModal):=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=BB=AC=EB=9F=BC=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=ED=95=84=EB=93=9C=20=EB=A0=88=EB=B2=A8?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 섹션 레벨 linkedFieldGroups 제거, 필드 레벨 linkedFieldGroup으로 변경 - FormFieldConfig에 linkedFieldGroup 속성 추가 (enabled, sourceTable, displayColumn, displayFormat, mappings) - select 필드 렌더링에서 linkedFieldGroup 활성화 시 다중 컬럼 저장 처리 - API 응답 파싱 개선 (responseData.data 구조 지원) - 저장 실패 시 상세 에러 메시지 표시 - ConfigPanel에 다중 컬럼 저장 설정 UI 및 HelpText 추가 --- .../UniversalFormModalComponent.tsx | 299 ++++----- .../UniversalFormModalConfigPanel.tsx | 606 ++++++++---------- .../components/universal-form-modal/types.ts | 9 + 3 files changed, 384 insertions(+), 530 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 65e079ae..4f2f5c6b 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -33,7 +33,6 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, - LinkedFieldGroup, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -121,6 +120,33 @@ export function UniversalFormModalComponent({ initializeForm(); }, [config, initialData]); + // 필드 레벨 linkedFieldGroup 데이터 로드 + useEffect(() => { + const loadData = async () => { + const tablesToLoad = new Set(); + + // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 + config.sections.forEach((section) => { + section.fields.forEach((field) => { + if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { + tablesToLoad.add(field.linkedFieldGroup.sourceTable); + } + }); + }); + + // 각 테이블 데이터 로드 + for (const tableName of tablesToLoad) { + if (!linkedFieldDataCache[tableName]) { + console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`); + await loadLinkedFieldData(tableName); + } + } + }; + + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections]); + // 폼 초기화 const initializeForm = useCallback(async () => { const newFormData: FormDataState = {}; @@ -364,18 +390,22 @@ export function UniversalFormModalComponent({ const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { page: 1, size: 1000, - autoFilter: true, // 현재 회사 기준 자동 필터링 + autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링 }); console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); if (response.data?.success) { - // data가 배열인지 확인 + // data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] } const responseData = response.data?.data; if (Array.isArray(responseData)) { + // 직접 배열인 경우 data = responseData; + } else if (responseData?.data && Array.isArray(responseData.data)) { + // { data: [...], total: ... } 형태 (tableManagementService 응답) + data = responseData.data; } else if (responseData?.rows && Array.isArray(responseData.rows)) { - // { rows: [...], total: ... } 형태일 수 있음 + // { rows: [...], total: ... } 형태 (다른 API 응답) data = responseData.rows; } console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); @@ -394,79 +424,6 @@ export function UniversalFormModalComponent({ [linkedFieldDataCache], ); - // 연동 필드 그룹 선택 시 매핑된 필드에 값 설정 - const handleLinkedFieldSelect = useCallback( - ( - group: LinkedFieldGroup, - selectedValue: string, - sectionId: string, - repeatItemId?: string - ) => { - // 캐시에서 데이터 찾기 - const sourceData = linkedFieldDataCache[group.sourceTable] || []; - const selectedRow = sourceData.find( - (row) => String(row[group.valueColumn]) === selectedValue - ); - - if (!selectedRow) { - console.warn("선택된 항목을 찾을 수 없습니다:", selectedValue); - return; - } - - // 매핑된 필드에 값 설정 - if (repeatItemId) { - // 반복 섹션 내 아이템 업데이트 - setRepeatSections((prev) => { - const sectionItems = prev[sectionId] || []; - const updatedItems = sectionItems.map((item) => { - if (item._id === repeatItemId) { - const updatedItem = { ...item }; - for (const mapping of group.mappings) { - updatedItem[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; - } - return updatedItem; - } - return item; - }); - return { ...prev, [sectionId]: updatedItems }; - }); - } else { - // 일반 섹션 필드 업데이트 - setFormData((prev) => { - const newData = { ...prev }; - for (const mapping of group.mappings) { - newData[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; - } - if (onChange) { - setTimeout(() => onChange(newData), 0); - } - return newData; - }); - } - }, - [linkedFieldDataCache, onChange], - ); - - // 연동 필드 그룹 표시 텍스트 생성 - const getLinkedFieldDisplayText = useCallback( - (group: LinkedFieldGroup, row: Record): string => { - const code = row[group.valueColumn] || ""; - const name = row[group.displayColumn] || ""; - - switch (group.displayFormat) { - case "name_only": - return name; - case "code_name": - return `${code} - ${name}`; - case "name_code": - return `${name} (${code})`; - default: - return name; - } - }, - [], - ); - // 필수 필드 검증 const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const missingFields: string[] = []; @@ -532,7 +489,13 @@ export function UniversalFormModalComponent({ } } catch (error: any) { console.error("저장 실패:", error); - toast.error(error.message || "저장에 실패했습니다."); + // axios 에러의 경우 서버 응답 메시지 추출 + const errorMessage = + error.response?.data?.message || + error.response?.data?.error?.details || + error.message || + "저장에 실패했습니다."; + toast.error(errorMessage); } finally { setSaving(false); } @@ -749,7 +712,88 @@ export function UniversalFormModalComponent({
); - case "select": + case "select": { + // 다중 컬럼 저장이 활성화된 경우 + const lfgMappings = field.linkedFieldGroup?.mappings; + if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) { + const lfg = field.linkedFieldGroup; + const sourceTableName = lfg.sourceTable as string; + const cachedData = linkedFieldDataCache[sourceTableName]; + const sourceData = Array.isArray(cachedData) ? cachedData : []; + + // 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용 + const valueColumn = lfgMappings[0].sourceColumn || ""; + + // 데이터 로드 (아직 없으면) + if (!cachedData && sourceTableName) { + loadLinkedFieldData(sourceTableName); + } + + // 표시 텍스트 생성 함수 + const getDisplayText = (row: Record): string => { + const displayVal = row[lfg.displayColumn || ""] || ""; + const valueVal = row[valueColumn] || ""; + switch (lfg.displayFormat) { + case "code_name": + return `${valueVal} - ${displayVal}`; + case "name_code": + return `${displayVal} (${valueVal})`; + case "name_only": + default: + return String(displayVal); + } + }; + + return ( + + ); + } + + // 일반 select 필드 return ( ); + } case "date": return ( @@ -854,64 +899,6 @@ export function UniversalFormModalComponent({ })(); }; - // 연동 필드 그룹 드롭다운 렌더링 - const renderLinkedFieldGroup = ( - group: LinkedFieldGroup, - sectionId: string, - repeatItemId?: string, - currentValue?: string, - sectionColumns: number = 2, - ) => { - const fieldKey = `linked_${group.id}_${repeatItemId || "main"}`; - const cachedData = linkedFieldDataCache[group.sourceTable]; - // 배열인지 확인하고, 아니면 빈 배열 사용 - const sourceData = Array.isArray(cachedData) ? cachedData : []; - const defaultSpan = Math.floor(12 / sectionColumns); - const actualGridSpan = sectionColumns === 1 ? 12 : group.gridSpan || defaultSpan; - - // 데이터 로드 (아직 없으면, 그리고 캐시에 없을 때만) - if (!cachedData && group.sourceTable) { - loadLinkedFieldData(group.sourceTable); - } - - return ( -
- - -
- ); - }; - // 섹션의 열 수에 따른 기본 gridSpan 계산 const getDefaultGridSpan = (sectionColumns: number = 2): number => { // 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3 @@ -999,18 +986,6 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} - {/* 연동 필드 그룹 렌더링 */} - {(section.linkedFieldGroups || []).map((group) => { - const firstMapping = group.mappings?.[0]; - const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; - return renderLinkedFieldGroup( - group, - section.id, - undefined, - currentValue ? String(currentValue) : undefined, - sectionColumns, - ); - })}
@@ -1033,19 +1008,6 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} - {/* 연동 필드 그룹 렌더링 */} - {(section.linkedFieldGroups || []).map((group) => { - // 매핑된 첫 번째 타겟 컬럼의 현재 값을 찾아서 선택 상태 표시 - const firstMapping = group.mappings?.[0]; - const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; - return renderLinkedFieldGroup( - group, - section.id, - undefined, - currentValue ? String(currentValue) : undefined, - sectionColumns, - ); - })}
@@ -1105,19 +1067,6 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} - {/* 연동 필드 그룹 렌더링 (반복 섹션 내) */} - {(section.linkedFieldGroups || []).map((group) => { - // 반복 섹션 아이템 내의 매핑된 첫 번째 타겟 컬럼 값 - const firstMapping = group.mappings?.[0]; - const currentValue = firstMapping ? item[firstMapping.targetColumn] : undefined; - return renderLinkedFieldGroup( - group, - section.id, - item._id, - currentValue ? String(currentValue) : undefined, - sectionColumns, - ); - })}
))} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index dc35a77e..acc53acc 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -37,7 +37,6 @@ import { UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, - LinkedFieldGroup, LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, @@ -49,11 +48,8 @@ import { defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, - defaultLinkedFieldGroupConfig, - defaultLinkedFieldMappingConfig, generateSectionId, generateFieldId, - generateLinkedFieldGroupId, } from "./config"; // 도움말 텍스트 컴포넌트 @@ -93,13 +89,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.tableName]); - // 연동 필드 그룹의 소스 테이블 컬럼 로드 + // 다중 컬럼 저장의 소스 테이블 컬럼 로드 useEffect(() => { const allSourceTables = new Set(); config.sections.forEach((section) => { - (section.linkedFieldGroups || []).forEach((group) => { - if (group.sourceTable) { - allSourceTables.add(group.sourceTable); + // 필드 레벨의 linkedFieldGroup 확인 + section.fields.forEach((field) => { + if (field.linkedFieldGroup?.sourceTable) { + allSourceTables.add(field.linkedFieldGroup.sourceTable); } }); }); @@ -578,47 +575,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 겸직 등 반복 데이터가 있는 섹션 - - - -
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, typeColumn: e.target.value }, - }) - } - placeholder="employment_type" - className="h-6 text-[10px] mt-1" - /> - 메인/서브를 구분하는 컬럼명 -
-
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, mainTypeValue: e.target.value }, - }) - } - className="h-6 text-[10px] mt-1" - /> -
-
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, subTypeValue: e.target.value }, - }) - } - className="h-6 text-[10px] mt-1" - /> -
)} @@ -683,7 +639,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor { @@ -866,305 +822,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} - {/* 연동 필드 그룹 설정 */} -
-
- 연동 필드 그룹 - -
-

- 부서코드/부서명 연동 저장 -

- - {(selectedSection.linkedFieldGroups || []).length > 0 && ( -
- {(selectedSection.linkedFieldGroups || []).map((group, groupIndex) => ( -
-
- - #{groupIndex + 1} - - -
- - {/* 라벨 */} -
- - { - const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => - g.id === group.id ? { ...g, label: e.target.value } : g - ); - updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); - }} - placeholder="예: 겸직부서" - className="h-5 text-[9px] mt-0.5" - /> -
- - {/* 소스 테이블 */} -
- - -
- - {/* 표시 형식 */} -
- - -
- - {/* 표시 컬럼 / 값 컬럼 */} -
-
- - -
-
- - -
-
- - {/* 필드 매핑 */} -
-
- - -
- - {(group.mappings || []).map((mapping, mappingIndex) => ( -
- - -> - - -
- ))} -
- - {/* 기타 옵션 */} -
-
- { - const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => - g.id === group.id ? { ...g, required: !!checked } : g - ); - updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); - }} - className="h-3 w-3" - /> - -
-
- - { - const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => - g.id === group.id ? { ...g, gridSpan: parseInt(e.target.value) || 6 } : g - ); - updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); - }} - className="h-4 w-8 text-[8px] px-1" - /> -
-
-
- ))} -
- )} -
- {/* 필드 목록 */} @@ -1467,7 +1124,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* Select 옵션 설정 */} {selectedField.fieldType === "select" && (
- + + 드롭다운에 표시될 옵션 목록을 어디서 가져올지 설정합니다. + {selectedField.selectOptions?.type === "static" && ( + 직접 입력: 옵션을 수동으로 입력합니다. (현재 미구현 - 테이블 참조 사용 권장) + )} + {selectedField.selectOptions?.type === "table" && (
+ 테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.
- + + 예: dept_info (부서 테이블)
- + @@ -1530,12 +1194,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="code" + placeholder="dept_code" className="h-6 text-[10px] mt-1" /> + 선택 시 실제 저장되는 값 (예: D001)
- + @@ -1546,15 +1211,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="name" + placeholder="dept_name" className="h-6 text-[10px] mt-1" /> + 드롭다운에 보여질 텍스트 (예: 영업부)
)} {selectedField.selectOptions?.type === "code" && (
+ 공통코드: 공통코드 테이블에서 옵션을 가져옵니다. + 예: POSITION_CODE (직급), STATUS_CODE (상태) 등 +
+ )} +
+ )} + + {/* 다중 컬럼 저장 (select 타입만) */} + {selectedField.fieldType === "select" && ( +
+
+ 다중 컬럼 저장 + + updateField(selectedSection.id, selectedField.id, { + linkedFieldGroup: { + ...selectedField.linkedFieldGroup, + enabled: checked, + }, + }) + } + /> +
+ + 드롭다운 선택 시 여러 컬럼에 동시 저장합니다. +
예: 부서 선택 시 부서코드 + 부서명을 각각 다른 컬럼에 저장 +
+ + {selectedField.linkedFieldGroup?.enabled && ( +
+ {/* 소스 테이블 */} +
+ + + 드롭다운 옵션을 가져올 테이블 +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 표시 컬럼 / 값 컬럼 */} +
+
+ + + 사용자가 드롭다운에서 보게 될 텍스트 (예: 영업부, 개발부) +
+
+ + {/* 저장할 컬럼 매핑 */} +
+
+ + +
+ 드롭다운 선택 시 소스 테이블의 어떤 값을 어떤 컬럼에 저장할지 설정 + + {(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => ( +
+
+ 매핑 #{mappingIndex + 1} + +
+
+ + +
+
+ + +
+
+ ))} + + {(selectedField.linkedFieldGroup?.mappings || []).length === 0 && ( +

+ + 버튼을 눌러 매핑을 추가하세요 +

+ )} +
)}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 11ccfd25..de2526c2 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -74,6 +74,15 @@ export interface FormFieldConfig { // Select 옵션 selectOptions?: SelectOptionConfig; + // 다중 컬럼 저장 (드롭다운 선택 시 여러 컬럼에 동시 저장) + linkedFieldGroup?: { + enabled?: boolean; // 사용 여부 + sourceTable?: string; // 소스 테이블 (예: dept_info) + displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 + displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식 + mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨) + }; + // 유효성 검사 validation?: FieldValidationConfig; From ce7847b13da1e5919e2bbc0c4f8c5c5c08ef8c21 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 18:15:20 +0900 Subject: [PATCH 21/24] =?UTF-8?q?feat(SplitPanelLayout2):=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=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 - JoinTableConfig 타입 정의 (joinTable, joinType, mainColumn, joinColumn, selectColumns) - RightPanelConfig.joinTables 배열 추가로 다중 조인 지원 - loadJoinTableData(), mergeJoinData() 함수로 클라이언트 사이드 조인 처리 - JoinTableItem 컴포넌트로 조인 테이블 설정 UI 제공 - 표시 컬럼에 sourceTable 추가로 테이블별 컬럼 구분 - 메인+조인 테이블 컬럼 통합 로드 기능 --- .../SplitPanelLayout2Component.tsx | 110 +++- .../SplitPanelLayout2ConfigPanel.tsx | 563 ++++++++++++++++-- .../components/split-panel-layout2/types.ts | 33 + 3 files changed, 655 insertions(+), 51 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 0dd00543..e8400c49 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -7,6 +7,7 @@ import { ColumnConfig, DataTransferField, ActionButtonConfig, + JoinTableConfig, } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; @@ -128,6 +129,94 @@ export const SplitPanelLayout2Component: React.FC> => { + const resultMap = new Map(); + if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) { + return resultMap; + } + + // 메인 데이터에서 조인할 키 값들 추출 + const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))]; + if (joinKeys.length === 0) return resultMap; + + try { + console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`); + + const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { + page: 1, + size: 1000, + // 조인 키 값들로 필터링 + dataFilter: { + enabled: true, + matchType: "any", // OR 조건으로 여러 키 매칭 + filters: joinKeys.map((key, idx) => ({ + id: `join_key_${idx}`, + columnName: joinConfig.joinColumn, + operator: "equals", + value: String(key), + valueType: "static", + })), + }, + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + const joinData = response.data.data?.data || []; + // 조인 컬럼 값을 키로 하는 Map 생성 + joinData.forEach((item: any) => { + const key = item[joinConfig.joinColumn]; + if (key) { + resultMap.set(String(key), item); + } + }); + console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`); + } + } catch (error) { + console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error); + } + + return resultMap; + }, []); + + // 메인 데이터에 조인 테이블 데이터 병합 + const mergeJoinData = useCallback(( + mainData: any[], + joinConfig: JoinTableConfig, + joinDataMap: Map + ): any[] => { + return mainData.map((item) => { + const joinKey = item[joinConfig.mainColumn]; + const joinRow = joinDataMap.get(String(joinKey)); + + if (joinRow && joinConfig.selectColumns) { + // 선택된 컬럼만 병합 + const mergedItem = { ...item }; + joinConfig.selectColumns.forEach((col) => { + // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명 + const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; + // 메인 테이블에 같은 컬럼이 없으면 추가 + if (!(col in mergedItem)) { + mergedItem[col] = joinRow[col]; + } else if (joinConfig.alias) { + // 메인 테이블에 같은 컬럼이 있으면 alias로 추가 + mergedItem[targetKey] = joinRow[col]; + } + }); + return mergedItem; + } + + return item; + }); + }, []); + // 우측 데이터 로드 (좌측 선택 항목 기반) const loadRightData = useCallback(async (selectedItem: any) => { if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { @@ -173,7 +262,24 @@ export const SplitPanelLayout2Component: React.FC 0 && data.length > 0) { + console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`); + + for (const joinTableConfig of joinTables) { + const joinDataMap = await loadJoinTableData(joinTableConfig, data); + if (joinDataMap.size > 0) { + data = mergeJoinData(data, joinTableConfig, joinDataMap); + } + } + + console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`); + } + setRightData(data); console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); } else { @@ -196,7 +302,7 @@ export const SplitPanelLayout2Component: React.FC { diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index da520d92..1a32f2ca 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -28,7 +28,7 @@ import { import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types"; // lodash set 대체 함수 const setPath = (obj: any, path: string, value: any): any => { @@ -245,6 +245,68 @@ export const SplitPanelLayout2ConfigPanel: React.FC { + const loadJoinTableColumns = async () => { + const joinTables = config.rightPanel?.joinTables || []; + if (joinTables.length === 0 || !config.rightPanel?.tableName) return; + + // 메인 테이블 컬럼 먼저 로드 + try { + const mainResponse = await apiClient.get(`/table-management/tables/${config.rightPanel.tableName}/columns?size=200`); + let mainColumns: ColumnInfo[] = []; + + if (mainResponse.data?.success) { + const columnList = mainResponse.data.data?.columns || mainResponse.data.data || []; + mainColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + } + + // 조인 테이블들의 선택된 컬럼 추가 + const joinColumns: ColumnInfo[] = []; + for (const jt of joinTables) { + if (jt.joinTable && jt.selectColumns && jt.selectColumns.length > 0) { + try { + const joinResponse = await apiClient.get(`/table-management/tables/${jt.joinTable}/columns?size=200`); + if (joinResponse.data?.success) { + const columnList = joinResponse.data.data?.columns || joinResponse.data.data || []; + const transformedColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + + // 선택된 컬럼 추가 (테이블명으로 구분) + jt.selectColumns.forEach((selCol) => { + const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol); + if (col) { + joinColumns.push({ + ...col, + column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`, + }); + } + }); + } + } catch (error) { + console.error(`조인 테이블 ${jt.joinTable} 컬럼 로드 실패:`, error); + } + } + } + + // 메인 + 조인 컬럼 합치기 + setRightColumns([...mainColumns, ...joinColumns]); + console.log(`[loadJoinTableColumns] 우측 컬럼 로드 완료: 메인 ${mainColumns.length}개 + 조인 ${joinColumns.length}개`); + } catch (error) { + console.error("조인 테이블 컬럼 로드 실패:", error); + } + }; + + loadJoinTableColumns(); + }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); + // 테이블 선택 컴포넌트 const TableSelect: React.FC<{ value: string; @@ -388,13 +450,28 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; placeholder: string; - }> = ({ columns, value, onValueChange, placeholder }) => { + showTableName?: boolean; // 테이블명 표시 여부 + tableName?: string; // 메인 테이블명 (조인 컬럼과 구분용) + }> = ({ columns, value, onValueChange, placeholder, showTableName = false, tableName }) => { // 현재 선택된 값의 라벨 찾기 const selectedColumn = columns.find((col) => col.column_name === value); const displayValue = selectedColumn ? selectedColumn.column_comment || selectedColumn.column_name : value || ""; + // 컬럼이 조인 테이블에서 온 것인지 확인 (column_comment에 괄호가 있으면 조인 테이블) + const isJoinColumn = (col: ColumnInfo) => col.column_comment?.includes("(") && col.column_comment?.includes(")"); + + // 컬럼 표시 텍스트 생성 + const getColumnDisplayText = (col: ColumnInfo) => { + const label = col.column_comment || col.column_name; + if (showTableName && tableName && !isJoinColumn(col)) { + // 메인 테이블 컬럼에 테이블명 추가 + return `${label} (${tableName})`; + } + return label; + }; + return ( onUpdate("joinType", value)} + > + + + + + LEFT JOIN (데이터 없어도 표시) + INNER JOIN (데이터 있어야만 표시) + + + + + {/* 조인 조건 */} +
+ +
+
+ + onUpdate("mainColumn", value)} + placeholder="메인 테이블 컬럼" + /> +
+
=
+
+ + onUpdate("joinColumn", value)} + placeholder="조인 테이블 컬럼" + /> +
+
+
+ + {/* 가져올 컬럼 선택 */} +
+
+ + +
+

+ 조인 테이블에서 표시할 컬럼들을 선택하세요 +

+
+ {(joinTable.selectColumns || []).map((col, colIndex) => ( +
+ { + const current = [...(joinTable.selectColumns || [])]; + current[colIndex] = value; + onUpdate("selectColumns", current); + }} + placeholder="컬럼 선택" + /> + +
+ ))} + {(joinTable.selectColumns || []).length === 0 && ( +
+ 가져올 컬럼을 추가하세요 +
+ )} +
+
+ + ); + }; + // 표시 컬럼 추가 const addDisplayColumn = (side: "left" | "right") => { const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; @@ -440,14 +742,25 @@ export const SplitPanelLayout2ConfigPanel: React.FC { + const updateDisplayColumn = ( + side: "left" | "right", + index: number, + fieldOrPartial: keyof ColumnConfig | Partial, + value?: any + ) => { const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; const currentColumns = side === "left" ? [...(config.leftPanel?.displayColumns || [])] : [...(config.rightPanel?.displayColumns || [])]; if (currentColumns[index]) { - currentColumns[index] = { ...currentColumns[index], [field]: value }; + if (typeof fieldOrPartial === "object") { + // 여러 필드를 한 번에 업데이트 + currentColumns[index] = { ...currentColumns[index], ...fieldOrPartial }; + } else { + // 단일 필드 업데이트 + currentColumns[index] = { ...currentColumns[index], [fieldOrPartial]: value }; + } updateConfig(path, currentColumns); } }; @@ -687,6 +1000,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC + {/* 추가 조인 테이블 설정 */} +
+
+ + +
+

+ 다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다. +

+
+ {(config.rightPanel?.joinTables || []).map((joinTable, index) => ( + { + const current = [...(config.rightPanel?.joinTables || [])]; + if (typeof fieldOrPartial === "object") { + // 여러 필드를 한 번에 업데이트 + current[index] = { ...current[index], ...fieldOrPartial }; + } else { + // 단일 필드 업데이트 + current[index] = { ...current[index], [fieldOrPartial]: value }; + } + updateConfig("rightPanel.joinTables", current); + }} + onRemove={() => { + const current = config.rightPanel?.joinTables || []; + updateConfig( + "rightPanel.joinTables", + current.filter((_, i) => i !== index) + ); + }} + /> + ))} +
+
+ {/* 표시 컬럼 */}
@@ -696,52 +1069,144 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+

+ 테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요. +

- {(config.rightPanel?.displayColumns || []).map((col, index) => ( -
-
- 컬럼 {index + 1} - + {(config.rightPanel?.displayColumns || []).map((col, index) => { + // 선택 가능한 테이블 목록: 메인 테이블 + 조인 테이블들 + const availableTables = [ + config.rightPanel?.tableName, + ...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable), + ].filter(Boolean) as string[]; + + // 선택된 테이블의 컬럼만 필터링 + const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName; + const filteredColumns = rightColumns.filter((c) => { + // 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함) + const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")"); + + if (selectedSourceTable === config.rightPanel?.tableName) { + // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 + return !isJoinColumn; + } else { + // 조인 테이블 선택 시: 해당 테이블 컬럼만 + return c.column_comment?.includes(`(${selectedSourceTable})`); + } + }); + + // 테이블 라벨 가져오기 + const getTableLabel = (tableName: string) => { + const table = tables.find((t) => t.table_name === tableName); + return table?.table_comment || tableName; + }; + + return ( +
+
+ 컬럼 {index + 1} + +
+ + {/* 테이블 선택 */} +
+ + +
+ + {/* 컬럼 선택 */} +
+ + +
+ + {/* 표시 라벨 */} +
+ + updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
+ + {/* 표시 위치 */} +
+ + +
- updateDisplayColumn("right", index, "name", value)} - placeholder="컬럼 선택" - /> -
- - updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
-
- - -
-
- ))} + ); + })} {(config.rightPanel?.displayColumns || []).length === 0 && (
표시할 컬럼을 추가하세요 diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index 872563df..4c9f7cae 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -9,6 +9,7 @@ export interface ColumnConfig { name: string; // 컬럼명 label: string; // 표시 라벨 + sourceTable?: string; // 소스 테이블명 (메인 테이블 또는 조인 테이블) displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) width?: number; // 너비 (px) bold?: boolean; // 굵게 표시 @@ -94,6 +95,17 @@ export interface RightPanelConfig { actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id) emptyMessage?: string; // 데이터 없을 때 메시지 + + /** + * 추가 조인 테이블 설정 + * 메인 테이블에 다른 테이블을 JOIN하여 추가 정보를 함께 표시합니다. + * + * 사용 예시: + * - 메인 테이블: user_dept (부서-사용자 관계) + * - 조인 테이블: user_info (사용자 개인정보) + * - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시 + */ + joinTables?: JoinTableConfig[]; } /** @@ -104,6 +116,27 @@ export interface JoinConfig { rightColumn: string; // 우측 테이블의 조인 컬럼 } +/** + * 추가 조인 테이블 설정 + * 우측 패널의 메인 테이블에 다른 테이블을 JOIN하여 추가 컬럼을 가져옵니다. + * + * 예시: user_dept (메인) + user_info (조인) → 부서관계 + 개인정보 함께 표시 + * + * - joinTable: 조인할 테이블명 (예: user_info) + * - joinType: 조인 방식 (LEFT JOIN 권장) + * - mainColumn: 메인 테이블의 조인 컬럼 (예: user_id) + * - joinColumn: 조인 테이블의 조인 컬럼 (예: user_id) + * - selectColumns: 조인 테이블에서 가져올 컬럼들 (예: email, cell_phone) + */ +export interface JoinTableConfig { + joinTable: string; // 조인할 테이블명 + joinType: "LEFT" | "INNER"; // 조인 타입 (LEFT: 없어도 표시, INNER: 있어야만 표시) + mainColumn: string; // 메인 테이블의 조인 컬럼 + joinColumn: string; // 조인 테이블의 조인 컬럼 + selectColumns: string[]; // 조인 테이블에서 가져올 컬럼들 + alias?: string; // 테이블 별칭 (중복 컬럼명 구분용) +} + /** * 메인 설정 */ From b9265c97eeed8179fe470fb115b6a7749c9ca7f7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 11:33:35 +0900 Subject: [PATCH 22/24] =?UTF-8?q?feat(UniversalFormModal):=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20API=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AC=EC=9B=90+=EB=B6=80=EC=84=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EC=A0=80=EC=9E=A5=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomApiSaveConfig 타입 정의 (apiType, mainDeptFields, subDeptFields) - saveWithCustomApi() 함수 추가로 테이블 직접 저장 대신 전용 API 호출 - adminController에 saveUserWithDept(), getUserWithDept() API 추가 - user_info + user_dept 트랜잭션 저장, 메인 부서 변경 시 자동 겸직 전환 - ConfigPanel에 전용 API 저장 설정 UI 추가 - SplitPanelLayout2: getColumnValue()로 조인 테이블 컬럼 값 추출 개선 - 검색 컬럼 선택 시 표시 컬럼 기반으로 변경 --- .../src/controllers/adminController.ts | 394 +++++++++++++++- backend-node/src/routes/adminRoutes.ts | 4 + frontend/lib/api/user.ts | 124 +++++ .../SplitPanelLayout2Component.tsx | 48 +- .../SplitPanelLayout2ConfigPanel.tsx | 145 ++++-- .../UniversalFormModalComponent.tsx | 310 +++++++++---- .../UniversalFormModalConfigPanel.tsx | 433 +++++++++++++++--- .../components/universal-form-modal/types.ts | 41 ++ 8 files changed, 1311 insertions(+), 188 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 3ac5d26b..5bcda820 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3,7 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { Client } from "pg"; -import { query, queryOne } from "../database/db"; +import { query, queryOne, getPool } from "../database/db"; import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; @@ -3406,3 +3406,395 @@ export async function copyMenu( }); } } + +/** + * ============================================================ + * 사원 + 부서 통합 관리 API + * ============================================================ + * + * 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다. + * + * ## 핵심 기능 + * 1. user_info 테이블에 사원 개인정보 저장 + * 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장 + * 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환 + * 4. 트랜잭션으로 데이터 정합성 보장 + * + * ## 요청 데이터 구조 + * ```json + * { + * "userInfo": { + * "user_id": "string (필수)", + * "user_name": "string (필수)", + * "email": "string", + * "cell_phone": "string", + * "sabun": "string", + * ... + * }, + * "mainDept": { + * "dept_code": "string (필수)", + * "dept_name": "string", + * "position_name": "string" + * }, + * "subDepts": [ + * { + * "dept_code": "string (필수)", + * "dept_name": "string", + * "position_name": "string" + * } + * ] + * } + * ``` + */ + +// 사원 + 부서 저장 요청 타입 +interface UserWithDeptRequest { + userInfo: { + user_id: string; + user_name: string; + user_name_eng?: string; + user_password?: string; + email?: string; + tel?: string; + cell_phone?: string; + sabun?: string; + user_type?: string; + user_type_name?: string; + status?: string; + locale?: string; + // 메인 부서 정보 (user_info에도 저장) + dept_code?: string; + dept_name?: string; + position_code?: string; + position_name?: string; + }; + mainDept?: { + dept_code: string; + dept_name?: string; + position_name?: string; + }; + subDepts?: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + }>; + isUpdate?: boolean; // 수정 모드 여부 +} + +/** + * POST /api/admin/users/with-dept + * 사원 + 부서 통합 저장 API + */ +export const saveUserWithDept = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + const client = await getPool().connect(); + + try { + const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest; + const companyCode = req.user?.companyCode || "*"; + const currentUserId = req.user?.userId; + + logger.info("사원+부서 통합 저장 요청", { + userId: userInfo?.user_id, + mainDept: mainDept?.dept_code, + subDeptsCount: subDepts.length, + isUpdate, + companyCode, + }); + + // 필수값 검증 + if (!userInfo?.user_id || !userInfo?.user_name) { + res.status(400).json({ + success: false, + message: "사용자 ID와 이름은 필수입니다.", + error: { code: "REQUIRED_FIELD_MISSING" }, + }); + return; + } + + // 트랜잭션 시작 + await client.query("BEGIN"); + + // 1. 기존 사용자 확인 + const existingUser = await client.query( + "SELECT user_id FROM user_info WHERE user_id = $1", + [userInfo.user_id] + ); + const isExistingUser = existingUser.rows.length > 0; + + // 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우) + let encryptedPassword = null; + if (userInfo.user_password) { + encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password); + } + + // 3. user_info 저장 (UPSERT) + // mainDept가 있으면 user_info에도 메인 부서 정보 저장 + const deptCode = mainDept?.dept_code || userInfo.dept_code || null; + const deptName = mainDept?.dept_name || userInfo.dept_name || null; + const positionName = mainDept?.position_name || userInfo.position_name || null; + + if (isExistingUser) { + // 기존 사용자 수정 + const updateFields: string[] = []; + const updateValues: any[] = []; + let paramIndex = 1; + + // 동적으로 업데이트할 필드 구성 + const fieldsToUpdate: Record = { + user_name: userInfo.user_name, + user_name_eng: userInfo.user_name_eng, + email: userInfo.email, + tel: userInfo.tel, + cell_phone: userInfo.cell_phone, + sabun: userInfo.sabun, + user_type: userInfo.user_type, + user_type_name: userInfo.user_type_name, + status: userInfo.status || "active", + locale: userInfo.locale, + dept_code: deptCode, + dept_name: deptName, + position_code: userInfo.position_code, + position_name: positionName, + company_code: companyCode !== "*" ? companyCode : undefined, + }; + + // 비밀번호가 제공된 경우에만 업데이트 + if (encryptedPassword) { + fieldsToUpdate.user_password = encryptedPassword; + } + + for (const [key, value] of Object.entries(fieldsToUpdate)) { + if (value !== undefined) { + updateFields.push(`${key} = $${paramIndex}`); + updateValues.push(value); + paramIndex++; + } + } + + if (updateFields.length > 0) { + updateValues.push(userInfo.user_id); + await client.query( + `UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`, + updateValues + ); + } + } else { + // 새 사용자 등록 + await client.query( + `INSERT INTO user_info ( + user_id, user_name, user_name_eng, user_password, + email, tel, cell_phone, sabun, + user_type, user_type_name, status, locale, + dept_code, dept_name, position_code, position_name, + company_code, regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`, + [ + userInfo.user_id, + userInfo.user_name, + userInfo.user_name_eng || null, + encryptedPassword || null, + userInfo.email || null, + userInfo.tel || null, + userInfo.cell_phone || null, + userInfo.sabun || null, + userInfo.user_type || null, + userInfo.user_type_name || null, + userInfo.status || "active", + userInfo.locale || null, + deptCode, + deptName, + userInfo.position_code || null, + positionName, + companyCode !== "*" ? companyCode : null, + ] + ); + } + + // 4. user_dept 처리 + if (mainDept?.dept_code || subDepts.length > 0) { + // 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용) + const existingDepts = await client.query( + "SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1", + [userInfo.user_id] + ); + const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true); + + // 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환 + if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) { + logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", { + userId: userInfo.user_id, + oldMain: existingMainDept.dept_code, + newMain: mainDept.dept_code, + }); + + await client.query( + "UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2", + [userInfo.user_id, existingMainDept.dept_code] + ); + } + + // 4-3. 기존 겸직 부서 삭제 (메인 제외) + // 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제 + await client.query( + "DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false", + [userInfo.user_id] + ); + + // 4-4. 메인 부서 저장 (UPSERT) + if (mainDept?.dept_code) { + await client.query( + `INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at) + VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id, dept_code) DO UPDATE SET + is_primary = true, + dept_name = $3, + user_name = $4, + position_name = $5, + company_code = $6, + updated_at = NOW()`, + [ + userInfo.user_id, + mainDept.dept_code, + mainDept.dept_name || null, + userInfo.user_name, + mainDept.position_name || null, + companyCode !== "*" ? companyCode : null, + ] + ); + } + + // 4-5. 겸직 부서 저장 + for (const subDept of subDepts) { + if (!subDept.dept_code) continue; + + // 메인 부서와 같은 부서는 겸직으로 추가하지 않음 + if (mainDept?.dept_code === subDept.dept_code) continue; + + await client.query( + `INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at) + VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id, dept_code) DO UPDATE SET + is_primary = false, + dept_name = $3, + user_name = $4, + position_name = $5, + company_code = $6, + updated_at = NOW()`, + [ + userInfo.user_id, + subDept.dept_code, + subDept.dept_name || null, + userInfo.user_name, + subDept.position_name || null, + companyCode !== "*" ? companyCode : null, + ] + ); + } + } + + // 트랜잭션 커밋 + await client.query("COMMIT"); + + logger.info("사원+부서 통합 저장 완료", { + userId: userInfo.user_id, + isUpdate: isExistingUser, + }); + + res.json({ + success: true, + message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.", + data: { + userId: userInfo.user_id, + isUpdate: isExistingUser, + }, + }); + } catch (error: any) { + // 트랜잭션 롤백 + await client.query("ROLLBACK"); + + logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body }); + + // 중복 키 에러 처리 + if (error.code === "23505") { + res.status(400).json({ + success: false, + message: "이미 존재하는 사용자 ID입니다.", + error: { code: "DUPLICATE_USER_ID" }, + }); + return; + } + + res.status(500).json({ + success: false, + message: "사원 저장 중 오류가 발생했습니다.", + error: { code: "SAVE_ERROR", details: error.message }, + }); + } finally { + client.release(); + } +} + +/** + * GET /api/admin/users/:userId/with-dept + * 사원 + 부서 정보 조회 API (수정 모달용) + */ +export const getUserWithDept = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { userId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + logger.info("사원+부서 조회 요청", { userId, companyCode }); + + // 1. user_info 조회 + let userQuery = "SELECT * FROM user_info WHERE user_id = $1"; + const userParams: any[] = [userId]; + + // 최고 관리자가 아니면 회사 필터링 + if (companyCode !== "*") { + userQuery += " AND company_code = $2"; + userParams.push(companyCode); + } + + const userResult = await query(userQuery, userParams); + + if (userResult.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + error: { code: "USER_NOT_FOUND" }, + }); + return; + } + + const userInfo = userResult[0]; + + // 2. user_dept 조회 (메인 + 겸직) + let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC"; + const deptResult = await query(deptQuery, [userId]); + + const mainDept = deptResult.find((d: any) => d.is_primary === true); + const subDepts = deptResult.filter((d: any) => d.is_primary === false); + + res.json({ + success: true, + data: { + userInfo, + mainDept: mainDept || null, + subDepts, + }, + }); + } catch (error: any) { + logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId }); + res.status(500).json({ + success: false, + message: "사원 조회 중 오류가 발생했습니다.", + error: { code: "QUERY_ERROR", details: error.message }, + }); + } +} diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 188e5580..b9964962 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -18,6 +18,8 @@ import { getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 + saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) + getUserWithDept, // 사원 + 부서 조회 (NEW!) getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyByCode, // 회사 단건 조회 @@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 +router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!) router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.post("/users", saveUser); // 사용자 등록/수정 (기존) +router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!) router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 83c725c2..6a829042 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) { return response.data; } +// ============================================================ +// 사원 + 부서 통합 관리 API +// ============================================================ + +/** + * 사원 + 부서 정보 저장 요청 타입 + */ +export interface SaveUserWithDeptRequest { + userInfo: { + user_id: string; + user_name: string; + user_name_eng?: string; + user_password?: string; + email?: string; + tel?: string; + cell_phone?: string; + sabun?: string; + user_type?: string; + user_type_name?: string; + status?: string; + locale?: string; + dept_code?: string; + dept_name?: string; + position_code?: string; + position_name?: string; + }; + mainDept?: { + dept_code: string; + dept_name?: string; + position_name?: string; + }; + subDepts?: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + }>; + isUpdate?: boolean; +} + +/** + * 사원 + 부서 정보 응답 타입 + */ +export interface UserWithDeptResponse { + userInfo: Record; + mainDept: { + dept_code: string; + dept_name?: string; + position_name?: string; + is_primary: boolean; + } | null; + subDepts: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + is_primary: boolean; + }>; +} + +/** + * 사원 + 부서 통합 저장 + * + * user_info와 user_dept 테이블에 트랜잭션으로 동시 저장합니다. + * - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환 + * - 겸직 부서는 전체 삭제 후 재입력 방식 + * + * @param data 저장할 사원 및 부서 정보 + * @returns 저장 결과 + */ +export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise> { + try { + console.log("사원+부서 통합 저장 API 호출:", data); + + const response = await apiClient.post("/admin/users/with-dept", data); + + console.log("사원+부서 통합 저장 API 응답:", response.data); + return response.data; + } catch (error: any) { + console.error("사원+부서 통합 저장 API 오류:", error); + + // Axios 에러 응답 처리 + if (error.response?.data) { + return error.response.data; + } + + return { + success: false, + message: error.message || "사원 저장 중 오류가 발생했습니다.", + }; + } +} + +/** + * 사원 + 부서 정보 조회 (수정 모달용) + * + * user_info와 user_dept 정보를 함께 조회합니다. + * + * @param userId 조회할 사용자 ID + * @returns 사원 정보 및 부서 관계 정보 + */ +export async function getUserWithDept(userId: string): Promise> { + try { + console.log("사원+부서 조회 API 호출:", userId); + + const response = await apiClient.get(`/admin/users/${userId}/with-dept`); + + console.log("사원+부서 조회 API 응답:", response.data); + return response.data; + } catch (error: any) { + console.error("사원+부서 조회 API 오류:", error); + + if (error.response?.data) { + return error.response.data; + } + + return { + success: false, + message: error.message || "사원 조회 중 오류가 발생했습니다.", + }; + } +} + // 사용자 API 객체로 export export const userAPI = { getList: getUserList, @@ -195,4 +316,7 @@ export const userAPI = { getCompanyList: getCompanyList, getDepartmentList: getDepartmentList, checkDuplicateId: checkDuplicateUserId, + // 사원 + 부서 통합 관리 + saveWithDept: saveUserWithDept, + getWithDept: getUserWithDept, }; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index e8400c49..3bdd2015 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -200,7 +200,11 @@ export const SplitPanelLayout2Component: React.FC { - // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명 + // 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용) + const tableColumnKey = `${joinConfig.joinTable}.${col}`; + mergedItem[tableColumnKey] = joinRow[col]; + + // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성) const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; // 메인 테이블에 같은 컬럼이 없으면 추가 if (!(col in mergedItem)) { @@ -210,6 +214,7 @@ export const SplitPanelLayout2Component: React.FC { + // col.name이 "테이블명.컬럼명" 형식인 경우 처리 + const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null; + const effectiveSourceTable = col.sourceTable || tableFromName; + + // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 + if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { + // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) + const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; + if (item[tableColumnKey] !== undefined) { + return item[tableColumnKey]; + } + // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 + const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable); + if (joinTable?.alias) { + const aliasKey = `${joinTable.alias}_${actualColName}`; + if (item[aliasKey] !== undefined) { + return item[aliasKey]; + } + } + // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) + if (item[actualColName] !== undefined) { + return item[actualColName]; + } + } + // 4. 기본: 컬럼명으로 직접 접근 + return item[actualColName]; + }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); + // 값 포맷팅 const formatValue = (value: any, format?: ColumnConfig["format"]): string => { if (value === null || value === undefined) return "-"; @@ -916,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{nameRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -931,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{infoRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -950,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{nameRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; if (idx === 0) { return ( @@ -971,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{infoRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -1079,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC ( - {formatValue(item[col.name], col.format)} + {formatValue(getColumnValue(item, col), col.format)} ))} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 1a32f2ca..c875316a 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -279,12 +279,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC { const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol); if (col) { joinColumns.push({ ...col, + // 유니크 키를 위해 테이블명_컬럼명 형태로 저장 + column_name: `${jt.joinTable}.${col.column_name}`, column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`, }); } @@ -727,8 +729,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC { - // 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함) - const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")"); + // 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태) + const isJoinColumn = c.column_name.includes("."); if (selectedSourceTable === config.rightPanel?.tableName) { // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 return !isJoinColumn; } else { - // 조인 테이블 선택 시: 해당 테이블 컬럼만 - return c.column_comment?.includes(`(${selectedSourceTable})`); + // 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태) + return c.column_name.startsWith(`${selectedSourceTable}.`); } }); @@ -1163,11 +1170,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC { // 조인 컬럼의 경우 테이블명 제거하고 표시 const displayLabel = c.column_comment?.replace(/\s*\([^)]+\)$/, "") || c.column_name; + // 실제 컬럼명 (테이블명.컬럼명에서 컬럼명만 추출) + const actualColumnName = c.column_name.includes(".") + ? c.column_name.split(".")[1] + : c.column_name; return ( {displayLabel} - {c.column_name} + {actualColumnName} ); @@ -1231,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC { const current = config.rightPanel?.searchColumns || []; updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]); @@ -1240,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+

+ 표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요. +

- {(config.rightPanel?.searchColumns || []).map((searchCol, index) => ( -
- { - const current = [...(config.rightPanel?.searchColumns || [])]; - current[index] = { ...current[index], columnName: value }; - updateConfig("rightPanel.searchColumns", current); - }} - placeholder="컬럼 선택" - /> - + {(config.rightPanel?.searchColumns || []).map((searchCol, index) => { + // 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시 + const displayColumns = config.rightPanel?.displayColumns || []; + + // 유효한 컬럼만 필터링 (name이 있는 것만) + const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== ""); + + // 현재 선택된 컬럼의 표시 정보 + const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName); + const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName); + const selectedLabel = selectedDisplayCol?.label || + selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || + searchCol.columnName; + const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || ""; + const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName; + + return ( +
+ + +
+ ); + })} + {(config.rightPanel?.displayColumns || []).length === 0 && ( +
+ 먼저 표시할 컬럼을 추가하세요
- ))} - {(config.rightPanel?.searchColumns || []).length === 0 && ( + )} + {(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
검색할 컬럼을 추가하세요
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 4f2f5c6b..3938645d 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -444,65 +444,8 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // 저장 처리 - const handleSave = useCallback(async () => { - if (!config.saveConfig.tableName) { - toast.error("저장할 테이블이 설정되지 않았습니다."); - return; - } - - // 필수 필드 검증 - const { valid, missingFields } = validateRequiredFields(); - if (!valid) { - toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); - return; - } - - setSaving(true); - - try { - const { multiRowSave } = config.saveConfig; - - if (multiRowSave?.enabled) { - // 다중 행 저장 - await saveMultipleRows(); - } else { - // 단일 행 저장 - await saveSingleRow(); - } - - // 저장 후 동작 - if (config.saveConfig.afterSave?.showToast) { - toast.success("저장되었습니다."); - } - - if (config.saveConfig.afterSave?.refreshParent) { - window.dispatchEvent(new CustomEvent("refreshParentData")); - } - - // onSave 콜백은 저장 완료 알림용으로만 사용 - // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) - // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 - // _saveCompleted 플래그를 포함하여 전달 - if (onSave) { - onSave({ ...formData, _saveCompleted: true }); - } - } catch (error: any) { - console.error("저장 실패:", error); - // axios 에러의 경우 서버 응답 메시지 추출 - const errorMessage = - error.response?.data?.message || - error.response?.data?.error?.details || - error.message || - "저장에 실패했습니다."; - toast.error(errorMessage); - } finally { - setSaving(false); - } - }, [config, formData, repeatSections, onSave, validateRequiredFields]); - // 단일 행 저장 - const saveSingleRow = async () => { + const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; // 메타데이터 필드 제거 @@ -534,15 +477,15 @@ export function UniversalFormModalComponent({ if (!response.data?.success) { throw new Error(response.data?.message || "저장 실패"); } - }; + }, [config.sections, config.saveConfig.tableName, formData]); // 다중 행 저장 (겸직 등) - const saveMultipleRows = async () => { + const saveMultipleRows = useCallback(async () => { const { multiRowSave } = config.saveConfig; if (!multiRowSave) return; - let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = - multiRowSave; + let { commonFields = [], repeatSectionId = "" } = multiRowSave; + const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave; // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 if (commonFields.length === 0) { @@ -563,56 +506,57 @@ export function UniversalFormModalComponent({ // 디버깅: 설정 확인 console.log("[UniversalFormModal] 다중 행 저장 설정:", { commonFields, - mainSectionFields, repeatSectionId, + mainSectionFields, typeColumn, mainTypeValue, subTypeValue, + repeatSections, + formData, }); - console.log("[UniversalFormModal] 현재 formData:", formData); - // 공통 필드 데이터 추출 - const commonData: Record = {}; - for (const fieldName of commonFields) { + // 반복 섹션 데이터 + const repeatItems = repeatSections[repeatSectionId] || []; + + // 저장할 행들 생성 + const rowsToSave: any[] = []; + + // 공통 데이터 (모든 행에 적용) + const commonData: any = {}; + commonFields.forEach((fieldName) => { if (formData[fieldName] !== undefined) { commonData[fieldName] = formData[fieldName]; } - } - console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData); + }); - // 메인 섹션 필드 데이터 추출 - const mainSectionData: Record = {}; - if (mainSectionFields && mainSectionFields.length > 0) { - for (const fieldName of mainSectionFields) { - if (formData[fieldName] !== undefined) { - mainSectionData[fieldName] = formData[fieldName]; - } + // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등) + const mainSectionData: any = {}; + mainSectionFields.forEach((fieldName) => { + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; } - } - console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData); + }); - // 저장할 행들 준비 - const rowsToSave: Record[] = []; + console.log("[UniversalFormModal] 공통 데이터:", commonData); + console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData); + console.log("[UniversalFormModal] 반복 항목:", repeatItems); - // 1. 메인 행 생성 - const mainRow: Record = { - ...commonData, - ...mainSectionData, - }; + // 메인 행 (공통 데이터 + 메인 섹션 필드) + const mainRow: any = { ...commonData, ...mainSectionData }; if (typeColumn) { mainRow[typeColumn] = mainTypeValue || "main"; } rowsToSave.push(mainRow); - // 2. 반복 섹션 행들 생성 (겸직 등) - const repeatItems = repeatSections[repeatSectionId] || []; + // 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드) for (const item of repeatItems) { - const subRow: Record = { ...commonData }; + const subRow: any = { ...commonData }; - // 반복 섹션 필드 복사 - Object.keys(item).forEach((key) => { - if (!key.startsWith("_")) { - subRow[key] = item[key]; + // 반복 섹션의 필드 값 추가 + const repeatSection = config.sections.find((s) => s.id === repeatSectionId); + repeatSection?.fields.forEach((field) => { + if (item[field.columnName] !== undefined) { + subRow[field.columnName] = item[field.columnName]; } }); @@ -666,7 +610,187 @@ export function UniversalFormModalComponent({ } console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); - }; + }, [config.sections, config.saveConfig, formData, repeatSections]); + + // 커스텀 API 저장 (사원+부서 통합 저장 등) + const saveWithCustomApi = useCallback(async () => { + const { customApiSave } = config.saveConfig; + if (!customApiSave) return; + + console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType); + + const saveUserWithDeptApi = async () => { + const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; + + // 1. userInfo 데이터 구성 + const userInfo: Record = {}; + + // 모든 필드에서 user_info에 해당하는 데이터 추출 + config.sections.forEach((section) => { + if (section.repeatable) return; // 반복 섹션은 제외 + + section.fields.forEach((field) => { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + userInfo[field.columnName] = value; + } + }); + }); + + // 2. mainDept 데이터 구성 + let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; + + if (mainDeptFields) { + const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; + if (deptCode) { + mainDept = { + dept_code: deptCode, + dept_name: formData[mainDeptFields.deptNameField || "dept_name"], + position_name: formData[mainDeptFields.positionNameField || "position_name"], + }; + } + } + + // 3. subDepts 데이터 구성 (반복 섹션에서) + const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; + + if (subDeptSectionId && repeatSections[subDeptSectionId]) { + const subDeptItems = repeatSections[subDeptSectionId]; + const deptCodeField = subDeptFields?.deptCodeField || "dept_code"; + const deptNameField = subDeptFields?.deptNameField || "dept_name"; + const positionNameField = subDeptFields?.positionNameField || "position_name"; + + subDeptItems.forEach((item) => { + const deptCode = item[deptCodeField]; + if (deptCode) { + subDepts.push({ + dept_code: deptCode, + dept_name: item[deptNameField], + position_name: item[positionNameField], + }); + } + }); + } + + // 4. API 호출 + console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts }); + + const { saveUserWithDept } = await import("@/lib/api/user"); + const response = await saveUserWithDept({ + userInfo: userInfo as any, + mainDept, + subDepts, + isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드 + }); + + if (!response.success) { + throw new Error(response.message || "사원 저장 실패"); + } + + console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data); + }; + + const saveWithGenericCustomApi = async () => { + if (!customApiSave.customEndpoint) { + throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다."); + } + + const dataToSave = { ...formData }; + + // 메타데이터 필드 제거 + Object.keys(dataToSave).forEach((key) => { + if (key.startsWith("_")) { + delete dataToSave[key]; + } + }); + + // 반복 섹션 데이터 포함 + if (Object.keys(repeatSections).length > 0) { + dataToSave._repeatSections = repeatSections; + } + + const method = customApiSave.customMethod || "POST"; + const response = method === "PUT" + ? await apiClient.put(customApiSave.customEndpoint, dataToSave) + : await apiClient.post(customApiSave.customEndpoint, dataToSave); + + if (!response.data?.success) { + throw new Error(response.data?.message || "저장 실패"); + } + }; + + switch (customApiSave.apiType) { + case "user-with-dept": + await saveUserWithDeptApi(); + break; + case "custom": + await saveWithGenericCustomApi(); + break; + default: + throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`); + } + }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + + // 저장 처리 + const handleSave = useCallback(async () => { + // 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크 + if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) { + toast.error("저장할 테이블이 설정되지 않았습니다."); + return; + } + + // 필수 필드 검증 + const { valid, missingFields } = validateRequiredFields(); + if (!valid) { + toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); + return; + } + + setSaving(true); + + try { + const { multiRowSave, customApiSave } = config.saveConfig; + + // 커스텀 API 저장 모드 + if (customApiSave?.enabled) { + await saveWithCustomApi(); + } else if (multiRowSave?.enabled) { + // 다중 행 저장 + await saveMultipleRows(); + } else { + // 단일 행 저장 + await saveSingleRow(); + } + + // 저장 후 동작 + if (config.saveConfig.afterSave?.showToast) { + toast.success("저장되었습니다."); + } + + if (config.saveConfig.afterSave?.refreshParent) { + window.dispatchEvent(new CustomEvent("refreshParentData")); + } + + // onSave 콜백은 저장 완료 알림용으로만 사용 + // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) + // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 + // _saveCompleted 플래그를 포함하여 전달 + if (onSave) { + onSave({ ...formData, _saveCompleted: true }); + } + } catch (error: any) { + console.error("저장 실패:", error); + // axios 에러의 경우 서버 응답 메시지 추출 + const errorMessage = + error.response?.data?.message || + error.response?.data?.error?.details || + error.message || + "저장에 실패했습니다."; + toast.error(errorMessage); + } finally { + setSaving(false); + } + }, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]); // 폼 초기화 const handleReset = useCallback(() => { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index acc53acc..8552cd6f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -416,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* 저장 테이블 - Combobox */}
- - - - - - - - - 테이블을 찾을 수 없습니다 - - {tables.map((t) => ( - { - updateSaveConfig({ tableName: t.name }); - setTableSelectOpen(false); - }} - className="text-xs" - > - - {t.name} - {t.label !== t.name && ( - ({t.label}) - )} - - ))} - - - - - - {config.saveConfig.tableName && ( -

- 컬럼 {currentColumns.length}개 로드됨 -

+ {config.saveConfig.customApiSave?.enabled ? ( +
+ 전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다. + {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( + 대상 테이블: user_info + user_dept + )} +
+ ) : ( + <> + + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + setTableSelectOpen(false); + }} + className="text-xs" + > + + {t.name} + {t.label !== t.name && ( + ({t.label}) + )} + + ))} + + + + + + {config.saveConfig.tableName && ( +

+ 컬럼 {currentColumns.length}개 로드됨 +

+ )} + )}
- {/* 다중 행 저장 설정 */} + {/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */} + {!config.saveConfig.customApiSave?.enabled && (
다중 행 저장 @@ -578,6 +590,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
)}
+ )} + + {/* 커스텀 API 저장 설정 */} +
+
+ 전용 API 저장 + + updateSaveConfig({ + customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" }, + }) + } + /> +
+ 테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다. + + {config.saveConfig.customApiSave?.enabled && ( +
+ {/* API 타입 선택 */} +
+ + +
+ + {/* 사원+부서 통합 저장 설정 */} + {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( +
+

+ user_info와 user_dept 테이블에 트랜잭션으로 저장합니다. + 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다. +

+ + {/* 메인 부서 필드 매핑 */} +
+ +
+
+ 부서코드: + +
+
+ 부서명: + +
+
+ 직급: + +
+
+
+ + {/* 겸직 부서 반복 섹션 */} +
+ + +
+ + {/* 겸직 부서 필드 매핑 */} + {config.saveConfig.customApiSave?.subDeptSectionId && ( +
+ +
+
+ 부서코드: + +
+
+ 부서명: + +
+
+ 직급: + +
+
+
+ )} +
+ )} + + {/* 커스텀 API 설정 */} + {config.saveConfig.customApiSave?.apiType === "custom" && ( +
+
+ + + updateSaveConfig({ + customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, + }) + } + placeholder="/api/custom/endpoint" + className="h-6 text-[10px] mt-1" + /> +
+
+ + +
+
+ )} +
+ )} +
{/* 저장 후 동작 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index de2526c2..04f7df0e 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -178,6 +178,9 @@ export interface SaveConfig { // 다중 행 저장 설정 multiRowSave?: MultiRowSaveConfig; + // 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용) + customApiSave?: CustomApiSaveConfig; + // 저장 후 동작 (간편 설정) showToast?: boolean; // 토스트 메시지 (기본: true) refreshParent?: boolean; // 부모 새로고침 (기본: true) @@ -191,6 +194,44 @@ export interface SaveConfig { }; } +/** + * 커스텀 API 저장 설정 + * + * 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다. + * 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다. + * + * ## 지원하는 API 타입 + * - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept) + * + * ## 데이터 매핑 설정 + * - `userInfoFields`: user_info 테이블에 저장할 필드 매핑 + * - `mainDeptFields`: 메인 부서 정보 필드 매핑 + * - `subDeptSectionId`: 겸직 부서 반복 섹션 ID + */ +export interface CustomApiSaveConfig { + enabled: boolean; + apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입 + + // user-with-dept 전용 설정 + userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName) + mainDeptFields?: { + deptCodeField?: string; // 메인 부서코드 필드명 + deptNameField?: string; // 메인 부서명 필드명 + positionNameField?: string; // 메인 직급 필드명 + }; + subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID + subDeptFields?: { + deptCodeField?: string; // 겸직 부서코드 필드명 + deptNameField?: string; // 겸직 부서명 필드명 + positionNameField?: string; // 겸직 직급 필드명 + }; + + // 커스텀 API 전용 설정 + customEndpoint?: string; // 커스텀 API 엔드포인트 + customMethod?: "POST" | "PUT"; // HTTP 메서드 + customDataTransform?: string; // 데이터 변환 함수명 (추후 확장) +} + // 모달 설정 export interface ModalConfig { title: string; From 3fc6bd55387be660d375a8f157d1a253ffc9b9b7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 15:34:19 +0900 Subject: [PATCH 23/24] =?UTF-8?q?feat(ModalRepeaterTable):=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EA=B2=80=EC=83=89=20=EB=AA=A8=EB=8B=AC=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=9D=BC=EB=B2=A8=20=EC=84=A4=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=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 - sourceColumnLabels 타입 정의 (Record) - ConfigPanel에 소스 컬럼별 표시 라벨 입력 UI 추가 - columnLabels 생성 시 sourceColumnLabels 우선 적용 - 컬럼 삭제 시 해당 라벨도 함께 삭제 - 빈 상태 안내 메시지 추가 --- .../ModalRepeaterTableComponent.tsx | 10 ++- .../ModalRepeaterTableConfigPanel.tsx | 82 ++++++++++++++----- .../components/modal-repeater-table/types.ts | 1 + 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 3a5b43dd..6e0432d1 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -185,6 +185,9 @@ export function ModalRepeaterTableComponent({ const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || []; const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== ""); + // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨) + const sourceColumnLabels = componentConfig?.sourceColumnLabels || {}; + const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || []; const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색"; const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색"; @@ -546,11 +549,12 @@ export function ModalRepeaterTableComponent({ handleChange(newData); }; - // 컬럼명 -> 라벨명 매핑 생성 + // 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴) const columnLabels = columns.reduce((acc, col) => { - acc[col.field] = col.label; + // sourceColumnLabels에 정의된 라벨 우선 사용 + acc[col.field] = sourceColumnLabels[col.field] || col.label; return acc; - }, {} as Record); + }, { ...sourceColumnLabels } as Record); return (
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 348ae045..507ab54d 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -520,7 +520,7 @@ export function ModalRepeaterTableConfigPanel({ {/* 소스 컬럼 */}
- +

- 모달 테이블에 표시할 컬럼들 + 모달 테이블에 표시할 컬럼과 헤더 라벨을 설정합니다

-
+
{(localConfig.sourceColumns || []).map((column, index) => ( -
- +
+
+ {/* 컬럼 선택 */} +
+ + +
+ {/* 라벨 입력 */} +
+ + { + const newLabels = { ...(localConfig.sourceColumnLabels || {}) }; + if (e.target.value) { + newLabels[column] = e.target.value; + } else { + delete newLabels[column]; + } + updateConfig({ sourceColumnLabels: newLabels }); + }} + placeholder={tableColumns.find(c => c.columnName === column)?.displayName || column || "라벨 입력"} + className="h-8 text-xs" + disabled={!column} + /> +
+
))} + {(localConfig.sourceColumns || []).length === 0 && ( +
+

+ "추가" 버튼을 클릭하여 모달에 표시할 컬럼을 추가하세요 +

+
+ )}
diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index 180830ee..c0cac4a9 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -7,6 +7,7 @@ export interface ModalRepeaterTableProps { // 소스 데이터 (모달에서 가져올 데이터) sourceTable: string; // 검색할 테이블 (예: "item_info") sourceColumns: string[]; // 모달에 표시할 컬럼들 + sourceColumnLabels?: Record; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨) sourceSearchFields?: string[]; // 검색 가능한 필드들 // 🆕 저장 대상 테이블 설정 From cd1777267f9294e281f344a8cfaa2e3f8e42e39b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 16:18:44 +0900 Subject: [PATCH 24/24] =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/taxInvoiceController.ts | 34 ++ backend-node/src/routes/taxInvoiceRoutes.ts | 3 + .../src/services/taxInvoiceService.ts | 176 +++++++++- .../components/tax-invoice/CostTypeStats.tsx | 329 ++++++++++++++++++ .../components/tax-invoice/TaxInvoiceForm.tsx | 24 +- .../components/tax-invoice/TaxInvoiceList.tsx | 24 +- frontend/lib/api/taxInvoice.ts | 61 ++++ 7 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 frontend/components/tax-invoice/CostTypeStats.tsx diff --git a/backend-node/src/controllers/taxInvoiceController.ts b/backend-node/src/controllers/taxInvoiceController.ts index 588a856c..5b7f4436 100644 --- a/backend-node/src/controllers/taxInvoiceController.ts +++ b/backend-node/src/controllers/taxInvoiceController.ts @@ -36,6 +36,7 @@ export class TaxInvoiceController { end_date, search, buyer_name, + cost_type, } = req.query; const result = await TaxInvoiceService.getList(companyCode, { @@ -47,6 +48,7 @@ export class TaxInvoiceController { end_date: end_date as string | undefined, search: search as string | undefined, buyer_name: buyer_name as string | undefined, + cost_type: cost_type as any, }); res.json({ @@ -327,5 +329,37 @@ export class TaxInvoiceController { }); } } + + /** + * 비용 유형별 통계 조회 + * GET /api/tax-invoice/stats/cost-type + */ + static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { year, month } = req.query; + const targetYear = year ? parseInt(year as string, 10) : undefined; + const targetMonth = month ? parseInt(month as string, 10) : undefined; + + const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth); + + res.json({ + success: true, + data: result, + period: { year: targetYear, month: targetMonth }, + }); + } catch (error: any) { + logger.error("비용 유형별 통계 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "통계 조회 중 오류가 발생했습니다.", + }); + } + } } diff --git a/backend-node/src/routes/taxInvoiceRoutes.ts b/backend-node/src/routes/taxInvoiceRoutes.ts index aa663faf..1a4bc6f0 100644 --- a/backend-node/src/routes/taxInvoiceRoutes.ts +++ b/backend-node/src/routes/taxInvoiceRoutes.ts @@ -18,6 +18,9 @@ router.get("/", TaxInvoiceController.getList); // 월별 통계 (상세 조회보다 먼저 정의해야 함) router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats); +// 비용 유형별 통계 +router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats); + // 상세 조회 router.get("/:id", TaxInvoiceController.getById); diff --git a/backend-node/src/services/taxInvoiceService.ts b/backend-node/src/services/taxInvoiceService.ts index 63e94d5e..73577bb0 100644 --- a/backend-node/src/services/taxInvoiceService.ts +++ b/backend-node/src/services/taxInvoiceService.ts @@ -6,6 +6,9 @@ import { query, transaction } from "../database/db"; import { logger } from "../utils/logger"; +// 비용 유형 타입 +export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; + // 세금계산서 타입 정의 export interface TaxInvoice { id: string; @@ -46,6 +49,9 @@ export interface TaxInvoice { // 첨부파일 (JSON 배열로 저장) attachments: TaxInvoiceAttachment[] | null; + // 비용 유형 (구매/설치/수리/유지보수/폐기/기타) + cost_type: CostType | null; + created_date: string; updated_date: string; writer: string; @@ -99,6 +105,7 @@ export interface CreateTaxInvoiceDto { customer_id?: string; items?: CreateTaxInvoiceItemDto[]; attachments?: TaxInvoiceAttachment[]; // 첨부파일 + cost_type?: CostType; // 비용 유형 } export interface CreateTaxInvoiceItemDto { @@ -121,6 +128,7 @@ export interface TaxInvoiceListParams { end_date?: string; search?: string; buyer_name?: string; + cost_type?: CostType; // 비용 유형 필터 } export class TaxInvoiceService { @@ -169,6 +177,7 @@ export class TaxInvoiceService { end_date, search, buyer_name, + cost_type, } = params; const offset = (page - 1) * pageSize; @@ -214,6 +223,12 @@ export class TaxInvoiceService { paramIndex++; } + if (cost_type) { + conditions.push(`cost_type = $${paramIndex}`); + values.push(cost_type); + paramIndex++; + } + const whereClause = conditions.join(" AND "); // 전체 개수 조회 @@ -282,13 +297,13 @@ export class TaxInvoiceService { supplier_business_type, supplier_business_item, buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email, supply_amount, tax_amount, total_amount, invoice_date, - remarks, order_id, customer_id, attachments, writer + remarks, order_id, customer_id, attachments, cost_type, writer ) VALUES ( $1, $2, $3, 'draft', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, - $19, $20, $21, $22, $23 + $19, $20, $21, $22, $23, $24 ) RETURNING *`, [ companyCode, @@ -313,6 +328,7 @@ export class TaxInvoiceService { data.order_id || null, data.customer_id || null, data.attachments ? JSON.stringify(data.attachments) : null, + data.cost_type || null, userId, ] ); @@ -402,6 +418,7 @@ export class TaxInvoiceService { invoice_date = COALESCE($17, invoice_date), remarks = COALESCE($18, remarks), attachments = $19, + cost_type = COALESCE($20, cost_type), updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING *`, @@ -425,6 +442,7 @@ export class TaxInvoiceService { data.invoice_date, data.remarks, data.attachments ? JSON.stringify(data.attachments) : null, + data.cost_type, ] ); @@ -608,5 +626,159 @@ export class TaxInvoiceService { return stats; } + + /** + * 비용 유형별 통계 조회 + */ + static async getCostTypeStats( + companyCode: string, + year?: number, + month?: number + ): Promise<{ + by_cost_type: Array<{ + cost_type: CostType | null; + count: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + }>; + by_month: Array<{ + year_month: string; + cost_type: CostType | null; + count: number; + total_amount: number; + }>; + summary: { + total_count: number; + total_amount: number; + purchase_amount: number; + installation_amount: number; + repair_amount: number; + maintenance_amount: number; + disposal_amount: number; + other_amount: number; + }; + }> { + const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"]; + const values: any[] = [companyCode]; + let paramIndex = 2; + + // 연도/월 필터 + if (year && month) { + const startDate = `${year}-${String(month).padStart(2, "0")}-01`; + const endDate = new Date(year, month, 0).toISOString().split("T")[0]; + conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`); + values.push(startDate, endDate); + paramIndex += 2; + } else if (year) { + conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`); + values.push(year); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // 비용 유형별 집계 + const byCostType = await query<{ + cost_type: CostType | null; + count: string; + supply_amount: string; + tax_amount: string; + total_amount: string; + }>( + `SELECT + cost_type, + COUNT(*) as count, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE ${whereClause} + GROUP BY cost_type + ORDER BY total_amount DESC`, + values + ); + + // 월별 비용 유형 집계 + const byMonth = await query<{ + year_month: string; + cost_type: CostType | null; + count: string; + total_amount: string; + }>( + `SELECT + TO_CHAR(invoice_date, 'YYYY-MM') as year_month, + cost_type, + COUNT(*) as count, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE ${whereClause} + GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type + ORDER BY year_month DESC, cost_type`, + values + ); + + // 전체 요약 + const summaryResult = await query<{ + total_count: string; + total_amount: string; + purchase_amount: string; + installation_amount: string; + repair_amount: string; + maintenance_amount: string; + disposal_amount: string; + other_amount: string; + }>( + `SELECT + COUNT(*) as total_count, + COALESCE(SUM(total_amount), 0) as total_amount, + COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount, + COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount, + COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount, + COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount, + COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount, + COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount + FROM tax_invoice + WHERE ${whereClause}`, + values + ); + + const summary = summaryResult[0] || { + total_count: "0", + total_amount: "0", + purchase_amount: "0", + installation_amount: "0", + repair_amount: "0", + maintenance_amount: "0", + disposal_amount: "0", + other_amount: "0", + }; + + return { + by_cost_type: byCostType.map((row) => ({ + cost_type: row.cost_type, + count: parseInt(row.count, 10), + supply_amount: parseFloat(row.supply_amount), + tax_amount: parseFloat(row.tax_amount), + total_amount: parseFloat(row.total_amount), + })), + by_month: byMonth.map((row) => ({ + year_month: row.year_month, + cost_type: row.cost_type, + count: parseInt(row.count, 10), + total_amount: parseFloat(row.total_amount), + })), + summary: { + total_count: parseInt(summary.total_count, 10), + total_amount: parseFloat(summary.total_amount), + purchase_amount: parseFloat(summary.purchase_amount), + installation_amount: parseFloat(summary.installation_amount), + repair_amount: parseFloat(summary.repair_amount), + maintenance_amount: parseFloat(summary.maintenance_amount), + disposal_amount: parseFloat(summary.disposal_amount), + other_amount: parseFloat(summary.other_amount), + }, + }; + } } diff --git a/frontend/components/tax-invoice/CostTypeStats.tsx b/frontend/components/tax-invoice/CostTypeStats.tsx new file mode 100644 index 00000000..786c093a --- /dev/null +++ b/frontend/components/tax-invoice/CostTypeStats.tsx @@ -0,0 +1,329 @@ +"use client"; + +/** + * 비용 유형별 통계 대시보드 + * 구매/설치/수리/유지보수/폐기 등 비용 정산 현황 + */ + +import { useState, useEffect, useCallback } from "react"; +import { + BarChart3, + TrendingUp, + TrendingDown, + Package, + Wrench, + Settings, + Trash2, + DollarSign, + Calendar, + RefreshCw, +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { toast } from "sonner"; + +import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice"; + +// 비용 유형별 아이콘 +const costTypeIcons: Record = { + purchase: , + installation: , + repair: , + maintenance: , + disposal: , + other: , +}; + +// 비용 유형별 색상 +const costTypeColors: Record = { + purchase: "bg-blue-500", + installation: "bg-green-500", + repair: "bg-orange-500", + maintenance: "bg-purple-500", + disposal: "bg-red-500", + other: "bg-gray-500", +}; + +export function CostTypeStats() { + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(undefined); + + // 연도 옵션 생성 (최근 5년) + const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i); + + // 월 옵션 생성 + const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1); + + // 데이터 로드 + const loadStats = useCallback(async () => { + setLoading(true); + try { + const response = await getCostTypeStats(selectedYear, selectedMonth); + if (response.success) { + setStats(response.data); + } + } catch (error: any) { + toast.error("통계 로드 실패", { description: error.message }); + } finally { + setLoading(false); + } + }, [selectedYear, selectedMonth]); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + // 금액 포맷 + const formatAmount = (amount: number) => { + if (amount >= 100000000) { + return `${(amount / 100000000).toFixed(1)}억`; + } + if (amount >= 10000) { + return `${(amount / 10000).toFixed(0)}만`; + } + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + // 전체 금액 대비 비율 계산 + const getPercentage = (amount: number) => { + if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0; + return (amount / stats.summary.total_amount) * 100; + }; + + return ( +
+ {/* 헤더 */} +
+
+

비용 정산 현황

+

구매/설치/수리/유지보수/폐기 비용 통계

+
+
+ + + +
+
+ + {/* 요약 카드 */} +
+ + + 총 비용 + + + +
+ {formatAmount(stats?.summary.total_amount || 0)}원 +
+

+ {stats?.summary.total_count || 0}건 +

+
+
+ + + + 구매 비용 + + + +
+ {formatAmount(stats?.summary.purchase_amount || 0)}원 +
+ +
+
+ + + + 수리/유지보수 + + + +
+ {formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}원 +
+ +
+
+ + + + 설치/폐기 + + + +
+ {formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}원 +
+ +
+
+
+ + {/* 비용 유형별 상세 */} + + + 비용 유형별 상세 + 각 비용 유형별 금액 및 비율 + + +
+ {stats?.by_cost_type && stats.by_cost_type.length > 0 ? ( + stats.by_cost_type.map((item) => { + const costType = item.cost_type as CostType; + const percentage = getPercentage(item.total_amount); + return ( +
+
+ {costType && costTypeIcons[costType]} + + {costType ? costTypeLabels[costType] : "미분류"} + +
+
+
+
+
+
+ + {percentage.toFixed(1)}% + +
+
+
+
+ {formatAmount(item.total_amount)}원 +
+
{item.count}건
+
+
+ ); + }) + ) : ( +
+ 데이터가 없습니다. +
+ )} +
+ + + + {/* 월별 추이 */} + {!selectedMonth && stats?.by_month && stats.by_month.length > 0 && ( + + + 월별 비용 추이 + {selectedYear}년 월별 비용 현황 + + +
+ {/* 월별 그룹핑 */} + {Array.from(new Set(stats.by_month.map((item) => item.year_month))) + .sort() + .reverse() + .slice(0, 6) + .map((yearMonth) => { + const monthData = stats.by_month.filter((item) => item.year_month === yearMonth); + const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0); + const [year, month] = yearMonth.split("-"); + + return ( +
+
+ {month}월 +
+
+ {monthData.map((item) => { + const costType = item.cost_type as CostType; + const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0; + return ( +
+ ); + })} +
+
+ {formatAmount(monthTotal)}원 +
+
+ ); + })} +
+ + {/* 범례 */} +
+ {Object.entries(costTypeLabels).map(([key, label]) => ( +
+
+ {label} +
+ ))} +
+ + + )} +
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx index 08c3fb37..9112ad33 100644 --- a/frontend/components/tax-invoice/TaxInvoiceForm.tsx +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -59,6 +59,8 @@ import { TaxInvoiceAttachment, CreateTaxInvoiceDto, CreateTaxInvoiceItemDto, + CostType, + costTypeLabels, } from "@/lib/api/taxInvoice"; import { apiClient } from "@/lib/api/client"; @@ -141,6 +143,7 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor tax_amount: inv.tax_amount, total_amount: inv.total_amount, remarks: inv.remarks, + cost_type: inv.cost_type || undefined, items: items.length > 0 ? items.map((item) => ({ @@ -344,7 +347,7 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor {/* 기본정보 탭 */} -
+
+
+ + +
({ value, label })), width: "90px" }, { key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select", filterOptions: [ { value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" }, { value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" } - ], width: "100px" }, + ], width: "90px" }, { key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" }, { key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" }, - { key: "attachments", label: "첨부", sortable: false, filterable: false, width: "60px", align: "center" }, + { key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" }, { key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" }, { key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" }, { key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" }, @@ -178,6 +182,7 @@ export function TaxInvoiceList() { ...filters, invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined, invoice_status: columnFilters.invoice_status, + cost_type: columnFilters.cost_type as CostType | undefined, search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined, }; @@ -614,13 +619,13 @@ export function TaxInvoiceList() { {loading ? ( - + 로딩 중... ) : invoices.length === 0 ? ( - + 세금계산서가 없습니다. @@ -634,6 +639,15 @@ export function TaxInvoiceList() { {typeLabels[invoice.invoice_type]} + + {invoice.cost_type ? ( + + {costTypeLabels[invoice.cost_type as CostType]} + + ) : ( + - + )} + {statusLabels[invoice.invoice_status]} diff --git a/frontend/lib/api/taxInvoice.ts b/frontend/lib/api/taxInvoice.ts index be41f24c..493f99a1 100644 --- a/frontend/lib/api/taxInvoice.ts +++ b/frontend/lib/api/taxInvoice.ts @@ -4,6 +4,19 @@ import { apiClient } from "./client"; +// 비용 유형 +export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; + +// 비용 유형 라벨 +export const costTypeLabels: Record = { + purchase: "구매", + installation: "설치", + repair: "수리", + maintenance: "유지보수", + disposal: "폐기", + other: "기타", +}; + // 세금계산서 타입 export interface TaxInvoice { id: string; @@ -31,6 +44,7 @@ export interface TaxInvoice { order_id: string | null; customer_id: string | null; attachments: TaxInvoiceAttachment[] | null; + cost_type: CostType | null; // 비용 유형 created_date: string; updated_date: string; writer: string; @@ -86,6 +100,7 @@ export interface CreateTaxInvoiceDto { customer_id?: string; items?: CreateTaxInvoiceItemDto[]; attachments?: TaxInvoiceAttachment[]; + cost_type?: CostType; // 비용 유형 } // 품목 생성 DTO @@ -110,6 +125,7 @@ export interface TaxInvoiceListParams { end_date?: string; search?: string; buyer_name?: string; + cost_type?: CostType; // 비용 유형 필터 } // 목록 응답 @@ -227,3 +243,48 @@ export async function getTaxInvoiceMonthlyStats( return response.data; } +// 비용 유형별 통계 응답 +export interface CostTypeStatsResponse { + success: boolean; + data: { + by_cost_type: Array<{ + cost_type: CostType | null; + count: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + }>; + by_month: Array<{ + year_month: string; + cost_type: CostType | null; + count: number; + total_amount: number; + }>; + summary: { + total_count: number; + total_amount: number; + purchase_amount: number; + installation_amount: number; + repair_amount: number; + maintenance_amount: number; + disposal_amount: number; + other_amount: number; + }; + }; + period: { year?: number; month?: number }; +} + +/** + * 비용 유형별 통계 조회 + */ +export async function getCostTypeStats( + year?: number, + month?: number +): Promise { + const params: Record = {}; + if (year) params.year = year; + if (month) params.month = month; + const response = await apiClient.get("/tax-invoice/stats/cost-type", { params }); + return response.data; +} +