From 486e5ee29b8ba95bc25997cca8a6d2cd08cbceb6 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 24 Dec 2025 14:01:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(SplitPanelLayout2):=20=EC=A2=8C=EC=9A=B0?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=8B=AB=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 패널에 수정/삭제 버튼 기능 추가 - 좌측 패널 설정에 개별 수정/삭제 UI 추가 - 삭제 API 호출을 백엔드 라우트에 맞게 수정 (DELETE /tables/{tableName}/delete) - UniversalFormModal 저장 완료 후 closeEditModal 이벤트 발생하여 모달 자동 닫기 - ModalConfig에 showSaveButton 타입 추가 --- .../SplitPanelLayout2Component.tsx | 219 +++++++++++++++--- .../SplitPanelLayout2ConfigPanel.tsx | 105 ++++++--- .../components/split-panel-layout2/types.ts | 3 + .../UniversalFormModalComponent.tsx | 28 ++- .../components/universal-form-modal/types.ts | 61 ++++- 5 files changed, 341 insertions(+), 75 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index ec52ba80..9c293867 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -86,6 +86,7 @@ export const SplitPanelLayout2Component: React.FC(null); const [isBulkDelete, setIsBulkDelete] = useState(false); + const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right"); // 탭 상태 (좌측/우측 각각) const [leftActiveTab, setLeftActiveTab] = useState(null); @@ -637,9 +638,6 @@ export const SplitPanelLayout2Component: React.FC { return config.rightPanel?.primaryKeyColumn || "id"; }, [config.rightPanel?.primaryKeyColumn]); + // 기본키 컬럼명 가져오기 (좌측 패널) + const getLeftPrimaryKeyColumn = useCallback(() => { + return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id"; + }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]); + // 우측 패널 수정 버튼 클릭 const handleEditItem = useCallback( (item: any) => { @@ -697,15 +700,54 @@ export const SplitPanelLayout2Component: React.FC { + // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) + const modalScreenId = config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 (수정 모드) + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: "수정", + modalSize: "lg", + editData: item, // 기존 데이터 전달 + isCreateMode: false, // 수정 모드 + onSave: () => { + loadLeftData(); + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", item); + }, + [config.leftPanel?.editModalScreenId, config.leftPanel?.addModalScreenId, loadLeftData], + ); + // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleDeleteClick = useCallback((item: any) => { setItemToDelete(item); setIsBulkDelete(false); + setDeleteTargetPanel("right"); + setDeleteDialogOpen(true); + }, []); + + // 좌측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) + const handleLeftDeleteClick = useCallback((item: any) => { + setItemToDelete(item); + setIsBulkDelete(false); + setDeleteTargetPanel("left"); setDeleteDialogOpen(true); }, []); @@ -716,41 +758,54 @@ export const SplitPanelLayout2Component: React.FC { - if (!config.rightPanel?.tableName) { + // 대상 패널에 따라 테이블명과 기본키 컬럼 결정 + const tableName = deleteTargetPanel === "left" + ? config.leftPanel?.tableName + : config.rightPanel?.tableName; + const pkColumn = deleteTargetPanel === "left" + ? getLeftPrimaryKeyColumn() + : getPrimaryKeyColumn(); + + if (!tableName) { toast.error("테이블 설정이 없습니다."); return; } - const pkColumn = getPrimaryKeyColumn(); - try { if (isBulkDelete) { - // 일괄 삭제 - const idsToDelete = Array.from(selectedRightItems); - console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete); + // 일괄 삭제 - 선택된 항목들의 데이터를 body로 전달 + const itemsToDelete = rightData.filter((item) => selectedRightItems.has(item[pkColumn] as string | number)); + console.log("[SplitPanelLayout2] 일괄 삭제:", itemsToDelete); - for (const id of idsToDelete) { - await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`); - } + // 백엔드 API는 body로 삭제할 데이터를 받음 + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: itemsToDelete, + }); - toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`); - setSelectedRightItems(new Set()); + toast.success(`${itemsToDelete.length}개 항목이 삭제되었습니다.`); + setSelectedRightItems(new Set()); } else if (itemToDelete) { - // 단일 삭제 - const itemId = itemToDelete[pkColumn]; - console.log("[SplitPanelLayout2] 단일 삭제:", itemId); + // 단일 삭제 - 해당 항목 데이터를 body로 전달 + console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete); - await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`); + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: itemToDelete, + }); toast.success("항목이 삭제되었습니다."); } // 데이터 새로고침 - if (selectedLeftItem) { + if (deleteTargetPanel === "left") { + loadLeftData(); + setSelectedLeftItem(null); // 좌측 선택 초기화 + setRightData([]); // 우측 데이터도 초기화 + } else if (selectedLeftItem) { loadRightData(selectedLeftItem); } } catch (error: any) { @@ -762,13 +817,18 @@ export const SplitPanelLayout2Component: React.FC d[pkColumn] === selectedId); if (item) { - handleEditItem(item); + // 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용 + const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: btn.label || "수정", + modalSize: "lg", + editData: item, + isCreateMode: false, + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); } } else if (selectedRightItems.size > 1) { toast.error("수정할 항목을 1개만 선택해주세요."); @@ -860,6 +942,57 @@ export const SplitPanelLayout2Component: React.FC { + switch (btn.action) { + case "add": + // 액션 버튼에 설정된 modalScreenId 우선 사용 + const modalScreenId = btn.modalScreenId || config.leftPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: btn.label || "추가", + modalSize: "lg", + editData: {}, + isCreateMode: true, + onSave: () => { + loadLeftData(); + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId); + break; + + case "edit": + // 좌측 패널에서 수정 (필요시 구현) + console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn); + break; + + case "delete": + // 좌측 패널에서 삭제 (필요시 구현) + console.log("[SplitPanelLayout2] 좌측 삭제 액션:", btn); + break; + + case "custom": + console.log("[SplitPanelLayout2] 좌측 커스텀 액션:", btn); + break; + + default: + break; + } + }, + [config.leftPanel?.addModalScreenId, loadLeftData], + ); + // 컬럼 라벨 로드 const loadColumnLabels = useCallback( async (tableName: string, setLabels: (labels: Record) => void) => { @@ -1012,10 +1145,10 @@ export const SplitPanelLayout2Component: React.FC { if (checked) { const pkColumn = getPrimaryKeyColumn(); - const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); + const allIds = new Set(filteredRightData.map((item) => item[pkColumn] as string | number)); setSelectedRightItems(allIds); } else { - setSelectedRightItems(new Set()); + setSelectedRightItems(new Set()); } }, [filteredRightData, getPrimaryKeyColumn], @@ -1348,6 +1481,27 @@ export const SplitPanelLayout2Component: React.FC )} + + {/* 좌측 패널 수정/삭제 버튼 */} + {(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && ( +
e.stopPropagation()}> + {config.leftPanel?.showEditButton && ( + + )} + {config.leftPanel?.showDeleteButton && ( + + )} +
+ )} {/* 자식 항목 */} @@ -1360,11 +1514,6 @@ export const SplitPanelLayout2Component: React.FC { - return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id"; - }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]); - // 왼쪽 패널 테이블 렌더링 const renderLeftTable = () => { const displayColumns = config.leftPanel?.displayColumns || []; @@ -1586,8 +1735,8 @@ export const SplitPanelLayout2Component: React.FC 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn])); - const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn])); + filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn] as string | number)); + const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn] as string | number)); return (
@@ -1633,7 +1782,7 @@ export const SplitPanelLayout2Component: React.FC ) : ( filteredRightData.map((item, index) => { - const itemId = item[pkColumn]; + const itemId = item[pkColumn] as string | number; return ( {showCheckbox && ( @@ -1962,11 +2111,7 @@ export const SplitPanelLayout2Component: React.FC { - if (btn.action === "add") { - handleLeftAddClick(); - } - }} + onClick={() => handleLeftActionButton(btn)} > {btn.icon && {btn.icon}} {btn.label || "버튼"} diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 1c7b7c77..5094a292 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -992,6 +992,42 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 개별 수정/삭제 버튼 (좌측) */} +
+ +

각 행에 표시되는 수정/삭제 버튼

+
+
+ + updateConfig("leftPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("leftPanel.showDeleteButton", checked)} + /> +
+ {(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && ( +
+ + updateConfig("leftPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

+ 수정/삭제 시 레코드 식별에 사용 +

+
+ )} +
+
+ {/* 탭 설정 (좌측) */}
@@ -1274,6 +1310,42 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 개별 수정/삭제 버튼 */} +
+ +

각 행에 표시되는 수정/삭제 버튼

+
+
+ + updateConfig("rightPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("rightPanel.showDeleteButton", checked)} + /> +
+ {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( +
+ + updateConfig("rightPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

+ 수정/삭제 시 레코드 식별에 사용 +

+
+ )} +
+
+ {/* 탭 설정 (우측) */}
@@ -1348,39 +1420,6 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateConfig("rightPanel.showCheckbox", checked)} />
- - {/* 수정/삭제 버튼 */} -
- -
- - updateConfig("rightPanel.showEditButton", checked)} - /> -
-
- - updateConfig("rightPanel.showDeleteButton", checked)} - /> -
-
- - {/* 기본키 컬럼 */} -
- - updateConfig("rightPanel.primaryKeyColumn", value)} - placeholder="기본키 컬럼 선택 (기본: id)" - /> -

- 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) -

-
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index 00c468e1..fbe8c912 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -158,6 +158,9 @@ export interface LeftPanelConfig { showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + showEditButton?: boolean; // 수정 버튼 표시 + showDeleteButton?: boolean; // 삭제 버튼 표시 + editModalScreenId?: number; // 수정 모달 화면 ID actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형) primaryKeyColumn?: string; // 기본키 컬럼명 (선택용, 기본: id) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index bc217299..64c2f826 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1223,12 +1223,11 @@ export function UniversalFormModalComponent({ if (subTableConfig.options?.saveMainAsFirst) { mainFieldMappings = []; - // 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑 - // 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑 + // fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만) for (const mapping of subTableConfig.fieldMappings || []) { if (mapping.targetColumn) { // 메인 데이터에서 동일한 컬럼명이 있으면 매핑 - if (mainData[mapping.targetColumn] !== undefined) { + if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") { mainFieldMappings.push({ formField: mapping.targetColumn, targetColumn: mapping.targetColumn, @@ -1239,7 +1238,7 @@ export function UniversalFormModalComponent({ config.sections.forEach((section) => { if (section.repeatable || section.type === "table") return; const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn); - if (matchingField && mainData[matchingField.columnName] !== undefined) { + if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") { mainFieldMappings!.push({ formField: matchingField.columnName, targetColumn: mapping.targetColumn, @@ -1374,6 +1373,11 @@ export function UniversalFormModalComponent({ if (onSave) { onSave({ ...formData, _saveCompleted: true }); } + + // 저장 완료 후 모달 닫기 이벤트 발생 + if (config.saveConfig.afterSave?.closeModal !== false) { + window.dispatchEvent(new CustomEvent("closeEditModal")); + } } catch (error: any) { console.error("저장 실패:", error); // axios 에러의 경우 서버 응답 메시지 추출 @@ -1492,6 +1496,22 @@ export function UniversalFormModalComponent({ return `${valueVal} - ${displayVal}`; case "name_code": return `${displayVal} (${valueVal})`; + case "custom": + // 커스텀 형식: {컬럼명}을 실제 값으로 치환 + if (lfg.customDisplayFormat) { + let result = lfg.customDisplayFormat; + // {컬럼명} 패턴을 찾아서 실제 값으로 치환 + const matches = result.match(/\{([^}]+)\}/g); + if (matches) { + matches.forEach((match) => { + const columnName = match.slice(1, -1); // { } 제거 + const columnValue = row[columnName]; + result = result.replace(match, columnValue !== undefined && columnValue !== null ? String(columnValue) : ""); + }); + } + return result; + } + return String(displayVal); case "name_only": default: return String(displayVal); diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 935d46be..43377764 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -81,7 +81,10 @@ export interface FormFieldConfig { enabled?: boolean; // 사용 여부 sourceTable?: string; // 소스 테이블 (예: dept_info) displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 - displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식 + displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식 + // 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용) + // 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)") + customDisplayFormat?: string; mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨) }; @@ -254,6 +257,53 @@ export interface TableSectionConfig { multiSelect?: boolean; // 다중 선택 허용 (기본: true) maxHeight?: string; // 테이블 최대 높이 (기본: "400px") }; + + // 7. 조건부 테이블 설정 (고급) + conditionalTable?: ConditionalTableConfig; +} + +/** + * 조건부 테이블 설정 + * 조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다. + * + * 사용 예시: + * - 품목검사정보: 검사유형(입고/공정/출고/재고/최종)별로 검사항목 관리 + * - BOM 관리: 품목유형별 자재 구성 + * - 공정 관리: 공정유형별 작업 항목 + */ +export interface ConditionalTableConfig { + enabled: boolean; + + // 트리거 UI 타입 + // - checkbox: 체크박스로 다중 선택 (선택된 조건들을 탭으로 표시) + // - dropdown: 드롭다운으로 단일 선택 + // - tabs: 모든 옵션을 탭으로 표시 + triggerType: "checkbox" | "dropdown" | "tabs"; + + // 조건 값을 저장할 컬럼 (예: inspection_type) + // 저장 시 각 행에 이 컬럼으로 조건 값이 자동 저장됨 + conditionColumn: string; + + // 조건 옵션 목록 + options: ConditionalTableOption[]; + + // 옵션을 테이블에서 동적으로 로드할 경우 + optionSource?: { + enabled: boolean; + tableName: string; // 예: inspection_type_code + valueColumn: string; // 예: type_code + labelColumn: string; // 예: type_name + filterCondition?: string; // 예: is_active = 'Y' + }; +} + +/** + * 조건부 테이블 옵션 + */ +export interface ConditionalTableOption { + id: string; + value: string; // 저장될 값 (예: "입고검사") + label: string; // 표시 라벨 (예: "입고검사") } /** @@ -650,6 +700,7 @@ export interface ModalConfig { showCloseButton?: boolean; // 버튼 설정 + showSaveButton?: boolean; // 저장 버튼 표시 (기본: true) saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장") cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소") showResetButton?: boolean; // 초기화 버튼 표시 @@ -748,6 +799,7 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [ { value: "name_only", label: "이름만 (예: 영업부)" }, { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, + { value: "custom", label: "커스텀 형식 (직접 입력)" }, ] as const; // ============================================ @@ -815,3 +867,10 @@ export const LOOKUP_CONDITION_SOURCE_OPTIONS = [ { value: "sectionField", label: "다른 섹션" }, { value: "externalTable", label: "외부 테이블" }, ] as const; + +// 조건부 테이블 트리거 타입 옵션 +export const CONDITIONAL_TABLE_TRIGGER_OPTIONS = [ + { value: "checkbox", label: "체크박스 (다중 선택)" }, + { value: "dropdown", label: "드롭다운 (단일 선택)" }, + { value: "tabs", label: "탭 (전체 표시)" }, +] as const;