From d550959cb7476112ea77852e08516da7669eda6c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 9 Dec 2025 14:55:49 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(modal-repeater-table):=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20Universal?= =?UTF-8?q?FormModal=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환 - 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원 - DynamicDataSourceConfig, MultiTableJoinStep 타입 추가 - 설정 패널에 동적 데이터 소스 설정 모달 추가 - UniversalFormModal: showSaveButton 옵션 추가 --- .../ModalRepeaterTableComponent.tsx | 194 ++++- .../ModalRepeaterTableConfigPanel.tsx | 800 +++++++++++++++++- .../modal-repeater-table/RepeaterTable.tsx | 94 +- .../components/modal-repeater-table/types.ts | 108 ++- .../UniversalFormModalComponent.tsx | 60 +- .../UniversalFormModalConfigPanel.tsx | 65 +- .../components/universal-form-modal/config.ts | 1 + 7 files changed, 1234 insertions(+), 88 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 6e0432d1..92eb4bb7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react"; import { ItemSelectionModal } from "./ItemSelectionModal"; import { RepeaterTable } from "./RepeaterTable"; -import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types"; +import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types"; import { useCalculation } from "./useCalculation"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -293,6 +293,9 @@ export function ModalRepeaterTableComponent({ // 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행) const [isOrderDateApplied, setIsOrderDateApplied] = useState(false); + + // 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID) + const [activeDataSources, setActiveDataSources] = useState>({}); // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { @@ -409,6 +412,193 @@ export function ModalRepeaterTableComponent({ }, [localValue, columnName, component?.id, onFormDataChange, targetTable]); const { calculateRow, calculateAll } = useCalculation(calculationRules); + + /** + * 동적 데이터 소스 변경 시 호출 + * 해당 컬럼의 모든 행 데이터를 새로운 소스에서 다시 조회 + */ + const handleDataSourceChange = async (columnField: string, optionId: string) => { + console.log(`🔄 데이터 소스 변경: ${columnField} → ${optionId}`); + + // 활성화 상태 업데이트 + setActiveDataSources((prev) => ({ + ...prev, + [columnField]: optionId, + })); + + // 해당 컬럼 찾기 + const column = columns.find((col) => col.field === columnField); + if (!column?.dynamicDataSource?.enabled) { + console.warn(`⚠️ 컬럼 "${columnField}"에 동적 데이터 소스가 설정되지 않음`); + return; + } + + // 선택된 옵션 찾기 + const option = column.dynamicDataSource.options.find((opt) => opt.id === optionId); + if (!option) { + console.warn(`⚠️ 옵션 "${optionId}"을 찾을 수 없음`); + return; + } + + // 모든 행에 대해 새 값 조회 + const updatedData = await Promise.all( + localValue.map(async (row, index) => { + try { + const newValue = await fetchDynamicValue(option, row); + console.log(` ✅ 행 ${index}: ${columnField} = ${newValue}`); + return { + ...row, + [columnField]: newValue, + }; + } catch (error) { + console.error(` ❌ 행 ${index} 조회 실패:`, error); + return row; + } + }) + ); + + // 계산 필드 업데이트 후 데이터 반영 + const calculatedData = calculateAll(updatedData); + handleChange(calculatedData); + }; + + /** + * 동적 데이터 소스 옵션에 따라 값 조회 + */ + async function fetchDynamicValue( + option: DynamicDataSourceOption, + rowData: any + ): Promise { + if (option.sourceType === "table" && option.tableConfig) { + // 테이블 직접 조회 (단순 조인) + const { tableName, valueColumn, joinConditions } = option.tableConfig; + + const whereConditions: Record = {}; + for (const cond of joinConditions) { + const value = rowData[cond.sourceField]; + if (value === undefined || value === null) { + console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`); + return undefined; + } + whereConditions[cond.targetField] = value; + } + + console.log(`🔍 테이블 조회: ${tableName}`, whereConditions); + + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { search: whereConditions, size: 1, page: 1 } + ); + + if (response.data.success && response.data.data?.data?.length > 0) { + return response.data.data.data[0][valueColumn]; + } + return undefined; + + } else if (option.sourceType === "multiTable" && option.multiTableConfig) { + // 테이블 복합 조인 (2개 이상 테이블 순차 조인) + const { joinChain, valueColumn } = option.multiTableConfig; + + if (!joinChain || joinChain.length === 0) { + console.warn("⚠️ 조인 체인이 비어있습니다."); + return undefined; + } + + console.log(`🔗 복합 조인 시작: ${joinChain.length}단계`); + + // 현재 값을 추적 (첫 단계는 현재 행에서 시작) + let currentValue: any = null; + let currentRow: any = null; + + for (let i = 0; i < joinChain.length; i++) { + const step = joinChain[i]; + const { tableName, joinCondition, outputField } = step; + + // 조인 조건 값 가져오기 + let fromValue: any; + if (i === 0) { + // 첫 번째 단계: 현재 행에서 값 가져오기 + fromValue = rowData[joinCondition.fromField]; + console.log(` 📍 단계 ${i + 1}: 현재행.${joinCondition.fromField} = ${fromValue}`); + } else { + // 이후 단계: 이전 조회 결과에서 값 가져오기 + fromValue = currentRow?.[joinCondition.fromField] || currentValue; + console.log(` 📍 단계 ${i + 1}: 이전결과.${joinCondition.fromField} = ${fromValue}`); + } + + if (fromValue === undefined || fromValue === null) { + console.warn(`⚠️ 단계 ${i + 1}: 조인 조건 값이 없습니다. (${joinCondition.fromField})`); + return undefined; + } + + // 테이블 조회 + const whereConditions: Record = { + [joinCondition.toField]: fromValue + }; + + console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions); + + try { + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { search: whereConditions, size: 1, page: 1 } + ); + + if (response.data.success && response.data.data?.data?.length > 0) { + currentRow = response.data.data.data[0]; + currentValue = outputField ? currentRow[outputField] : currentRow; + console.log(` ✅ 단계 ${i + 1} 성공:`, { outputField, value: currentValue }); + } else { + console.warn(` ⚠️ 단계 ${i + 1}: 조회 결과 없음`); + return undefined; + } + } catch (error) { + console.error(` ❌ 단계 ${i + 1} 조회 실패:`, error); + return undefined; + } + } + + // 최종 값 반환 (마지막 테이블에서 valueColumn 가져오기) + const finalValue = currentRow?.[valueColumn]; + console.log(`🎯 복합 조인 완료: ${valueColumn} = ${finalValue}`); + return finalValue; + + } else if (option.sourceType === "api" && option.apiConfig) { + // 전용 API 호출 (복잡한 다중 조인) + const { endpoint, method = "GET", parameterMappings, responseValueField } = option.apiConfig; + + // 파라미터 빌드 + const params: Record = {}; + for (const mapping of parameterMappings) { + const value = rowData[mapping.sourceField]; + if (value !== undefined && value !== null) { + params[mapping.paramName] = value; + } + } + + console.log(`🔍 API 호출: ${method} ${endpoint}`, params); + + let response; + if (method === "POST") { + response = await apiClient.post(endpoint, params); + } else { + response = await apiClient.get(endpoint, { params }); + } + + if (response.data.success && response.data.data) { + // responseValueField로 값 추출 (중첩 경로 지원: "data.price") + const keys = responseValueField.split("."); + let value = response.data.data; + for (const key of keys) { + value = value?.[key]; + } + return value; + } + return undefined; + } + + return undefined; + } // 초기 데이터에 계산 필드 적용 useEffect(() => { @@ -579,6 +769,8 @@ export function ModalRepeaterTableComponent({ onDataChange={handleChange} onRowChange={handleRowChange} onRowDelete={handleRowDelete} + activeDataSources={activeDataSources} + onDataSourceChange={handleDataSourceChange} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 507ab54d..914e34f1 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -9,7 +9,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; -import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule } from "./types"; +import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { cn } from "@/lib/utils"; @@ -169,6 +170,10 @@ export function ModalRepeaterTableConfigPanel({ const [openTableCombo, setOpenTableCombo] = useState(false); const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false); const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false); + + // 동적 데이터 소스 설정 모달 + const [dynamicSourceModalOpen, setDynamicSourceModalOpen] = useState(false); + const [editingDynamicSourceColumnIndex, setEditingDynamicSourceColumnIndex] = useState(null); // config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용) useEffect(() => { @@ -397,6 +402,101 @@ export function ModalRepeaterTableConfigPanel({ updateConfig({ calculationRules: rules }); }; + // 동적 데이터 소스 설정 함수들 + const openDynamicSourceModal = (columnIndex: number) => { + setEditingDynamicSourceColumnIndex(columnIndex); + setDynamicSourceModalOpen(true); + }; + + const toggleDynamicDataSource = (columnIndex: number, enabled: boolean) => { + const columns = [...(localConfig.columns || [])]; + if (enabled) { + columns[columnIndex] = { + ...columns[columnIndex], + dynamicDataSource: { + enabled: true, + options: [], + }, + }; + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dynamicDataSource, ...rest } = columns[columnIndex]; + columns[columnIndex] = rest; + } + updateConfig({ columns }); + }; + + const addDynamicSourceOption = (columnIndex: number) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + const newOption: DynamicDataSourceOption = { + id: `option_${Date.now()}`, + label: "새 옵션", + sourceType: "table", + tableConfig: { + tableName: "", + valueColumn: "", + joinConditions: [], + }, + }; + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + enabled: true, + options: [...(col.dynamicDataSource?.options || []), newOption], + }, + }; + updateConfig({ columns }); + }; + + const updateDynamicSourceOption = (columnIndex: number, optionIndex: number, updates: Partial) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + const options = [...(col.dynamicDataSource?.options || [])]; + options[optionIndex] = { ...options[optionIndex], ...updates }; + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + options, + }, + }; + updateConfig({ columns }); + }; + + const removeDynamicSourceOption = (columnIndex: number, optionIndex: number) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + const options = [...(col.dynamicDataSource?.options || [])]; + options.splice(optionIndex, 1); + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + options, + }, + }; + updateConfig({ columns }); + }; + + const setDefaultDynamicSourceOption = (columnIndex: number, optionId: string) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + defaultOptionId: optionId, + }, + }; + updateConfig({ columns }); + }; + return (
{/* 소스/저장 테이블 설정 */} @@ -1327,6 +1427,60 @@ export function ModalRepeaterTableConfigPanel({ )}
+ + {/* 6. 동적 데이터 소스 설정 */} +
+
+ + toggleDynamicDataSource(index, checked)} + /> +
+

+ 컬럼 헤더 클릭으로 데이터 소스 전환 (예: 거래처별 단가, 품목별 단가) +

+ + {col.dynamicDataSource?.enabled && ( +
+
+ + {col.dynamicDataSource.options.length}개 옵션 설정됨 + + +
+ + {/* 옵션 미리보기 */} + {col.dynamicDataSource.options.length > 0 && ( +
+ {col.dynamicDataSource.options.map((opt) => ( + + {opt.label} + {col.dynamicDataSource?.defaultOptionId === opt.id && " (기본)"} + + ))} +
+ )} +
+ )} +
))} @@ -1493,6 +1647,650 @@ export function ModalRepeaterTableConfigPanel({ + + {/* 동적 데이터 소스 설정 모달 */} + + + + + 동적 데이터 소스 설정 + {editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && ( + + ({localConfig.columns[editingDynamicSourceColumnIndex].label}) + + )} + + + 컬럼 헤더 클릭 시 선택할 수 있는 데이터 소스 옵션을 설정합니다 + + + + {editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && ( +
+ {/* 옵션 목록 */} +
+ {(localConfig.columns[editingDynamicSourceColumnIndex].dynamicDataSource?.options || []).map((option, optIndex) => ( +
+
+
+ 옵션 {optIndex + 1} + {localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId === option.id && ( + 기본 + )} +
+
+ {localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId !== option.id && ( + + )} + +
+
+ + {/* 옵션 라벨 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { label: e.target.value })} + placeholder="예: 거래처별 단가" + className="h-8 text-xs" + /> +
+ + {/* 소스 타입 */} +
+ + +
+ + {/* 테이블 직접 조회 설정 */} + {option.sourceType === "table" && ( +
+

테이블 조회 설정

+ + {/* 참조 테이블 */} +
+ + +
+ + {/* 값 컬럼 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + tableConfig: { ...option.tableConfig!, valueColumn: value }, + })} + /> +
+ + {/* 조인 조건 */} +
+
+ + +
+ + {(option.tableConfig?.joinConditions || []).map((cond, condIndex) => ( +
+ + = + { + const newConditions = [...(option.tableConfig?.joinConditions || [])]; + newConditions[condIndex] = { ...newConditions[condIndex], targetField: value }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + tableConfig: { ...option.tableConfig!, joinConditions: newConditions }, + }); + }} + /> + +
+ ))} +
+
+ )} + + {/* 테이블 복합 조인 설정 (2개 이상 테이블) */} + {option.sourceType === "multiTable" && ( +
+
+

복합 조인 설정

+

+ 여러 테이블을 순차적으로 조인합니다 +

+
+ + {/* 조인 체인 */} +
+
+ + +
+ + {/* 시작점 안내 */} +
+

시작: 현재 행 데이터

+

+ 첫 번째 조인은 현재 행의 필드에서 시작합니다 +

+
+ + {/* 조인 단계들 */} + {(option.multiTableConfig?.joinChain || []).map((step, stepIndex) => ( +
+
+
+
+ {stepIndex + 1} +
+ 조인 단계 {stepIndex + 1} +
+ +
+ + {/* 조인할 테이블 */} +
+ + +
+ + {/* 조인 조건 */} +
+
+ + {stepIndex === 0 ? ( + + ) : ( + { + const newChain = [...(option.multiTableConfig?.joinChain || [])]; + newChain[stepIndex] = { + ...newChain[stepIndex], + joinCondition: { ...newChain[stepIndex].joinCondition, fromField: e.target.value } + }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain }, + }); + }} + placeholder={option.multiTableConfig?.joinChain[stepIndex - 1]?.outputField || "이전 출력 필드"} + className="h-8 text-xs" + /> + )} +
+ +
+ = +
+ +
+ + { + const newChain = [...(option.multiTableConfig?.joinChain || [])]; + newChain[stepIndex] = { + ...newChain[stepIndex], + joinCondition: { ...newChain[stepIndex].joinCondition, toField: value } + }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain }, + }); + }} + /> +
+
+ + {/* 다음 단계로 전달할 필드 */} +
+ + { + const newChain = [...(option.multiTableConfig?.joinChain || [])]; + newChain[stepIndex] = { ...newChain[stepIndex], outputField: value }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain }, + }); + }} + /> +

+ {stepIndex < (option.multiTableConfig?.joinChain.length || 0) - 1 + ? "다음 조인 단계에서 사용할 필드" + : "마지막 단계면 비워두세요"} +

+
+ + {/* 조인 미리보기 */} + {step.tableName && step.joinCondition.fromField && step.joinCondition.toField && ( +
+ + {stepIndex === 0 ? "현재행" : option.multiTableConfig?.joinChain[stepIndex - 1]?.tableName} + + .{step.joinCondition.fromField} + = + {step.tableName} + .{step.joinCondition.toField} + {step.outputField && ( + + → {step.outputField} + + )} +
+ )} +
+ ))} + + {/* 조인 체인이 없을 때 안내 */} + {(!option.multiTableConfig?.joinChain || option.multiTableConfig.joinChain.length === 0) && ( +
+

+ 조인 체인이 없습니다 +

+

+ "조인 추가" 버튼을 클릭하여 테이블 조인을 설정하세요 +

+
+ )} +
+ + {/* 최종 값 컬럼 */} + {option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && ( +
+ + { + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, valueColumn: value }, + }); + }} + /> +

+ 마지막 테이블에서 가져올 값 +

+
+ )} + + {/* 전체 조인 경로 미리보기 */} + {option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && ( +
+

조인 경로 미리보기

+
+ {option.multiTableConfig.joinChain.map((step, idx) => ( +
+ {idx === 0 && ( + <> + 현재행 + .{step.joinCondition.fromField} + + + )} + {step.tableName} + .{step.joinCondition.toField} + {step.outputField && idx < option.multiTableConfig!.joinChain.length - 1 && ( + <> + + {step.outputField} + + )} +
+ ))} + {option.multiTableConfig.valueColumn && ( +
+ 최종 값: + {option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName}.{option.multiTableConfig.valueColumn} +
+ )} +
+
+ )} +
+ )} + + {/* API 호출 설정 */} + {option.sourceType === "api" && ( +
+

API 호출 설정

+

+ 복잡한 다중 조인은 백엔드 API로 처리합니다 +

+ + {/* API 엔드포인트 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + apiConfig: { ...option.apiConfig!, endpoint: e.target.value }, + })} + placeholder="/api/price/customer" + className="h-8 text-xs font-mono" + /> +
+ + {/* HTTP 메서드 */} +
+ + +
+ + {/* 파라미터 매핑 */} +
+
+ + +
+ + {(option.apiConfig?.parameterMappings || []).map((mapping, mapIndex) => ( +
+ { + const newMappings = [...(option.apiConfig?.parameterMappings || [])]; + newMappings[mapIndex] = { ...newMappings[mapIndex], paramName: e.target.value }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + apiConfig: { ...option.apiConfig!, parameterMappings: newMappings }, + }); + }} + placeholder="파라미터명" + className="h-7 text-[10px] flex-1" + /> + = + + +
+ ))} +
+ + {/* 응답 값 필드 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + apiConfig: { ...option.apiConfig!, responseValueField: e.target.value }, + })} + placeholder="price (또는 data.price)" + className="h-8 text-xs font-mono" + /> +

+ API 응답에서 값을 가져올 필드 (중첩 경로 지원: data.price) +

+
+
+ )} +
+ ))} + + {/* 옵션 추가 버튼 */} + +
+ + {/* 안내 */} +
+

사용 예시

+
    +
  • - 거래처별 단가: customer_item_price 테이블에서 조회
  • +
  • - 품목별 단가: item_info 테이블에서 기준 단가 조회
  • +
  • - 계약 단가: 전용 API로 복잡한 조인 처리
  • +
+
+
+ )} + + + + +
+
); } diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 703256b2..869884a7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -4,7 +4,8 @@ import React, { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; -import { Trash2 } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Trash2, ChevronDown, Check } from "lucide-react"; import { RepeaterColumnConfig } from "./types"; import { cn } from "@/lib/utils"; @@ -14,6 +15,9 @@ interface RepeaterTableProps { onDataChange: (newData: any[]) => void; onRowChange: (index: number, newRow: any) => void; onRowDelete: (index: number) => void; + // 동적 데이터 소스 관련 + activeDataSources?: Record; // 컬럼별 현재 활성화된 데이터 소스 ID + onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백 } export function RepeaterTable({ @@ -22,11 +26,16 @@ export function RepeaterTable({ onDataChange, onRowChange, onRowDelete, + activeDataSources = {}, + onDataSourceChange, }: RepeaterTableProps) { const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string; } | null>(null); + + // 동적 데이터 소스 Popover 열림 상태 + const [openPopover, setOpenPopover] = useState(null); // 데이터 변경 감지 (필요시 활성화) // useEffect(() => { @@ -144,16 +153,79 @@ export function RepeaterTable({ # - {columns.map((col) => ( - - {col.label} - {col.required && *} - - ))} + {columns.map((col) => { + const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; + const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; + const activeOption = hasDynamicSource + ? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] + : null; + + return ( + + {hasDynamicSource ? ( + setOpenPopover(open ? col.field : null)} + > + + + + +
+ 데이터 소스 선택 +
+ {col.dynamicDataSource!.options.map((option) => ( + + ))} +
+
+ ) : ( + <> + {col.label} + {col.required && *} + + )} + + ); + })} 삭제 diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index c0cac4a9..028a892b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -10,7 +10,7 @@ export interface ModalRepeaterTableProps { sourceColumnLabels?: Record; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨) sourceSearchFields?: string[]; // 검색 가능한 필드들 - // 🆕 저장 대상 테이블 설정 + // 저장 대상 테이블 설정 targetTable?: string; // 저장할 테이블 (예: "sales_order_mng") // 모달 설정 @@ -25,14 +25,14 @@ export interface ModalRepeaterTableProps { calculationRules?: CalculationRule[]; // 자동 계산 규칙 // 데이터 - value: any[]; // 현재 추가된 항목들 - onChange: (newData: any[]) => void; // 데이터 변경 콜백 + value: Record[]; // 현재 추가된 항목들 + onChange: (newData: Record[]) => void; // 데이터 변경 콜백 // 중복 체크 uniqueField?: string; // 중복 체크할 필드 (예: "item_code") // 필터링 - filterCondition?: Record; + filterCondition?: Record; companyCode?: string; // 스타일 @@ -47,11 +47,92 @@ export interface RepeaterColumnConfig { calculated?: boolean; // 계산 필드 여부 width?: string; // 컬럼 너비 required?: boolean; // 필수 입력 여부 - defaultValue?: any; // 기본값 + defaultValue?: string | number | boolean; // 기본값 selectOptions?: { value: string; label: string }[]; // select일 때 옵션 - - // 🆕 컬럼 매핑 설정 + + // 컬럼 매핑 설정 mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정 + + // 동적 데이터 소스 (컬럼 헤더 클릭으로 데이터 소스 전환) + dynamicDataSource?: DynamicDataSourceConfig; +} + +/** + * 동적 데이터 소스 설정 + * 컬럼 헤더를 클릭하여 데이터 소스를 전환할 수 있는 기능 + * 예: 거래처별 단가, 품목별 단가, 기준 단가 등을 선택 + */ +export interface DynamicDataSourceConfig { + enabled: boolean; + options: DynamicDataSourceOption[]; + defaultOptionId?: string; // 기본 선택 옵션 ID +} + +/** + * 동적 데이터 소스 옵션 + * 각 옵션은 다른 테이블/API에서 데이터를 가져오는 방법을 정의 + */ +export interface DynamicDataSourceOption { + id: string; + label: string; // 표시 라벨 (예: "거래처별 단가") + + // 조회 방식 + sourceType: "table" | "multiTable" | "api"; + + // 테이블 직접 조회 (단순 조인 - 1개 테이블) + tableConfig?: { + tableName: string; // 참조 테이블명 + valueColumn: string; // 가져올 값 컬럼 + joinConditions: { + sourceField: string; // 현재 행의 필드 + targetField: string; // 참조 테이블의 필드 + }[]; + }; + + // 테이블 복합 조인 (2개 이상 테이블 조인) + multiTableConfig?: { + // 조인 체인 정의 (순서대로 조인) + joinChain: MultiTableJoinStep[]; + // 최종적으로 가져올 값 컬럼 (마지막 테이블에서) + valueColumn: string; + }; + + // 전용 API 호출 (복잡한 다중 조인) + apiConfig?: { + endpoint: string; // API 엔드포인트 (예: "/api/price/customer") + method?: "GET" | "POST"; // HTTP 메서드 (기본: GET) + parameterMappings: { + paramName: string; // API 파라미터명 + sourceField: string; // 현재 행의 필드 + }[]; + responseValueField: string; // 응답에서 값을 가져올 필드 + }; +} + +/** + * 복합 조인 단계 정의 + * 예: item_info.item_number → customer_item.item_code → customer_item.id → customer_item_price.customer_item_id + */ +export interface MultiTableJoinStep { + // 조인할 테이블 + tableName: string; + // 조인 조건 + joinCondition: { + // 이전 단계의 필드 (첫 번째 단계는 현재 행의 필드) + fromField: string; + // 이 테이블의 필드 + toField: string; + }; + // 다음 단계로 전달할 필드 (다음 조인에 사용) + outputField?: string; + // 추가 필터 조건 (선택사항) + additionalFilters?: { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<="; + value: string | number | boolean; + // 값이 현재 행에서 오는 경우 + valueFromField?: string; + }[]; } /** @@ -61,16 +142,16 @@ export interface RepeaterColumnConfig { export interface ColumnMapping { /** 매핑 타입 */ type: "source" | "reference" | "manual"; - + /** 매핑 타입별 설정 */ // type: "source" - 소스 테이블 (모달에서 선택한 항목)의 컬럼에서 가져오기 sourceField?: string; // 소스 테이블의 컬럼명 (예: "item_name") - + // type: "reference" - 외부 테이블 참조 (조인) referenceTable?: string; // 참조 테이블명 (예: "customer_item_mapping") referenceField?: string; // 참조 테이블에서 가져올 컬럼 (예: "basic_price") joinCondition?: JoinCondition[]; // 조인 조건 - + // type: "manual" - 사용자가 직접 입력 } @@ -101,11 +182,10 @@ export interface ItemSelectionModalProps { sourceColumns: string[]; sourceSearchFields?: string[]; multiSelect?: boolean; - filterCondition?: Record; + filterCondition?: Record; modalTitle: string; - alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용) + alreadySelected: Record[]; // 이미 선택된 항목들 (중복 방지용) uniqueField?: string; - onSelect: (items: any[]) => void; + onSelect: (items: Record[]) => void; columnLabels?: Record; // 컬럼명 -> 라벨명 매핑 } - diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 11b3aa43..3fa8f623 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1027,7 +1027,7 @@ export function UniversalFormModalComponent({ }} disabled={isDisabled} > - + @@ -1409,47 +1409,37 @@ export function UniversalFormModalComponent({ {/* 섹션들 */}
{config.sections.map((section) => renderSection(section))}
- {/* 버튼 영역 */} -
- {config.modal.showResetButton && ( + {/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */} + {config.modal.showSaveButton !== false && ( +
+ {config.modal.showResetButton && ( + + )} - )} - - -
+
+ )} {/* 삭제 확인 다이얼로그 */} - + diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index e503ebfb..3ce7477a 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -402,21 +402,26 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor -
- - updateModalConfig({ saveButtonText: e.target.value })} - className="h-7 text-xs mt-1" - /> -
-
- - updateModalConfig({ cancelButtonText: e.target.value })} - className="h-7 text-xs mt-1" - /> +
+
+ 저장 버튼 표시 + updateModalConfig({ showSaveButton: checked })} + /> +
+ ButtonPrimary 컴포넌트로 저장 버튼을 별도 구성할 경우 끄세요 + + {config.modal.showSaveButton !== false && ( +
+ + updateModalConfig({ saveButtonText: e.target.value })} + className="h-7 text-xs mt-1" + /> +
+ )}
@@ -1896,7 +1901,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.
- + - 예: dept_info (부서 테이블) + 드롭다운 목록을 가져올 테이블을 선택하세요
- + @@ -1933,13 +1938,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="dept_code" - className="h-6 text-[10px] mt-1" + placeholder="customer_code" + className="h-7 text-xs mt-1" /> - 선택 시 실제 저장되는 값 (예: D001) + + 참조 테이블에서 조인할 컬럼을 선택하세요 +
+ 예: customer_code, customer_id +
- + @@ -1950,10 +1959,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="dept_name" - className="h-6 text-[10px] mt-1" + placeholder="customer_name" + className="h-7 text-xs mt-1" /> - 드롭다운에 보여질 텍스트 (예: 영업부) + + 드롭다운에 표시할 컬럼을 선택하세요 +
+ 예: customer_name, company_name +
)} diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 5383512b..66644576 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -12,6 +12,7 @@ export const defaultConfig: UniversalFormModalConfig = { size: "lg", closeOnOutsideClick: false, showCloseButton: true, + showSaveButton: true, saveButtonText: "저장", cancelButtonText: "취소", showResetButton: false, From 5e97a3a5e97b5f7d0b74ebc0df0f64e1e1c45d1d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 9 Dec 2025 16:11:04 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20UniversalFormModal?= =?UTF-8?q?=20beforeFormSave=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - screenManagementService: PostgreSQL regexp_replace로 정확한 최대 번호 조회 - CopyScreenModal: linkedScreens 의존성 추가로 모달 코드 생성 보장 - UniversalFormModal: beforeFormSave 이벤트 리스너로 ButtonPrimary 연동 - 설정된 필드만 병합하여 의도치 않은 덮어쓰기 방지 --- .../src/services/screenManagementService.ts | 41 +++++++++------- .../components/screen/CopyScreenModal.tsx | 14 +++++- .../ModalRepeaterTableConfigPanel.tsx | 2 +- .../modal-repeater-table/RepeaterTable.tsx | 16 +++--- .../components/modal-repeater-table/types.ts | 8 +-- .../UniversalFormModalComponent.tsx | 49 +++++++++++++++++++ .../UniversalFormModalConfigPanel.tsx | 16 +++--- 7 files changed, 104 insertions(+), 42 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6628cf4c..9fc0f079 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2360,30 +2360,33 @@ export class ScreenManagementService { const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0); await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 현재 최대 번호 조회 - const existingScreens = await client.query<{ screen_code: string }>( - `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + // 현재 최대 번호 조회 (숫자 추출 후 정렬) + // 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX + const existingScreens = await client.query<{ screen_code: string; num: number }>( + `SELECT screen_code, + COALESCE( + NULLIF( + regexp_replace(screen_code, $2, '\\1'), + screen_code + )::integer, + 0 + ) as num + FROM screen_definitions + WHERE company_code = $1 + AND screen_code ~ $2 + AND deleted_date IS NULL + ORDER BY num DESC + LIMIT 1`, + [companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`] ); let maxNumber = 0; - const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` - ); - - for (const screen of existingScreens.rows) { - const match = screen.screen_code.match(pattern); - if (match) { - const number = parseInt(match[1], 10); - if (number > maxNumber) { - maxNumber = number; - } - } + if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) { + maxNumber = existingScreens.rows[0].num; } + console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode} → ${maxNumber}`); + // count개의 코드를 순차적으로 생성 const codes: string[] = []; for (let i = 0; i < count; i++) { diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index c37603c5..f5e71c4c 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -166,18 +166,28 @@ export default function CopyScreenModal({ // linkedScreens 로딩이 완료되면 화면 코드 생성 useEffect(() => { + // 모달 화면들의 코드가 모두 설정되었는지 확인 + const allModalCodesSet = linkedScreens.length === 0 || + linkedScreens.every(screen => screen.newScreenCode); + console.log("🔍 코드 생성 조건 체크:", { targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreensCount: linkedScreens.length, + allModalCodesSet, }); - if (targetCompanyCode && !loadingLinkedScreens && !screenCode) { + // 조건: 회사 코드가 있고, 로딩이 완료되고, (메인 코드가 없거나 모달 코드가 없을 때) + const needsCodeGeneration = targetCompanyCode && + !loadingLinkedScreens && + (!screenCode || (linkedScreens.length > 0 && !allModalCodesSet)); + + if (needsCodeGeneration) { console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")"); generateScreenCodes(); } - }, [targetCompanyCode, loadingLinkedScreens, screenCode]); + }, [targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreens]); // 회사 목록 조회 const loadCompanies = async () => { diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 914e34f1..7a11bdb1 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -1438,7 +1438,7 @@ export function ModalRepeaterTableConfigPanel({ checked={col.dynamicDataSource?.enabled || false} onCheckedChange={(checked) => toggleDynamicDataSource(index, checked)} /> -
+

컬럼 헤더 클릭으로 데이터 소스 전환 (예: 거래처별 단가, 품목별 단가)

diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 869884a7..410fd9a6 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -161,11 +161,11 @@ export function RepeaterTable({ : null; return ( - + {hasDynamicSource ? ( ) : ( <> - {col.label} - {col.required && *} + {col.label} + {col.required && *} )} - + ); })} diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index 028a892b..6097aaf3 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -49,7 +49,7 @@ export interface RepeaterColumnConfig { required?: boolean; // 필수 입력 여부 defaultValue?: string | number | boolean; // 기본값 selectOptions?: { value: string; label: string }[]; // select일 때 옵션 - + // 컬럼 매핑 설정 mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정 @@ -142,16 +142,16 @@ export interface MultiTableJoinStep { export interface ColumnMapping { /** 매핑 타입 */ type: "source" | "reference" | "manual"; - + /** 매핑 타입별 설정 */ // type: "source" - 소스 테이블 (모달에서 선택한 항목)의 컬럼에서 가져오기 sourceField?: string; // 소스 테이블의 컬럼명 (예: "item_name") - + // type: "reference" - 외부 테이블 참조 (조인) referenceTable?: string; // 참조 테이블명 (예: "customer_item_mapping") referenceField?: string; // 참조 테이블에서 가져올 컬럼 (예: "basic_price") joinCondition?: JoinCondition[]; // 조인 조건 - + // type: "manual" - 사용자가 직접 입력 } diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 3fa8f623..7598d3a8 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -144,6 +144,55 @@ export function UniversalFormModalComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); + // 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달 + // 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지 + useEffect(() => { + const handleBeforeFormSave = (event: Event) => { + if (!(event instanceof CustomEvent) || !event.detail?.formData) return; + + // 설정에 정의된 필드 columnName 목록 수집 + const configuredFields = new Set(); + config.sections.forEach((section) => { + section.fields.forEach((field) => { + if (field.columnName) { + configuredFields.add(field.columnName); + } + }); + }); + + console.log("[UniversalFormModal] beforeFormSave 이벤트 수신"); + console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields)); + + // UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함) + // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 + // (UniversalFormModal이 해당 필드의 주인이므로) + for (const [key, value] of Object.entries(formData)) { + // 설정에 정의된 필드만 병합 + if (configuredFields.has(key)) { + if (value !== undefined && value !== null && value !== "") { + event.detail.formData[key] = value; + console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value); + } + } + } + + // 반복 섹션 데이터도 병합 (필요한 경우) + if (Object.keys(repeatSections).length > 0) { + for (const [sectionId, items] of Object.entries(repeatSections)) { + const sectionKey = `_repeatSection_${sectionId}`; + event.detail.formData[sectionKey] = items; + console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items); + } + } + }; + + window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + + return () => { + window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + }; + }, [formData, repeatSections, config.sections]); + // 필드 레벨 linkedFieldGroup 데이터 로드 useEffect(() => { const loadData = async () => { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 3ce7477a..48542342 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -413,14 +413,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ButtonPrimary 컴포넌트로 저장 버튼을 별도 구성할 경우 끄세요 {config.modal.showSaveButton !== false && ( -
- - updateModalConfig({ saveButtonText: e.target.value })} - className="h-7 text-xs mt-1" - /> -
+
+ + updateModalConfig({ saveButtonText: e.target.value })} + className="h-7 text-xs mt-1" + /> +
)} From fa6c00b6bee58b0d532a672cfe76cab867c0890d Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 17:41:41 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=9E=98=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=EA=B1=B0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/EditModal.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2a3050fc..3815fc71 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -678,12 +678,13 @@ export const EditModal: React.FC = ({ className }) => { } // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + // 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간 const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3) const dialogGap = 16; // DialogContent gap-4 const extraPadding = 24; // 추가 여백 (안전 마진) + const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유) - const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding; + const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace; return { className: "overflow-hidden p-0", @@ -729,7 +730,7 @@ export const EditModal: React.FC = ({ className }) => { className="relative bg-white" style={{ width: screenDimensions?.width || 800, - height: screenDimensions?.height || 600, + height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가 transformOrigin: "center center", maxWidth: "100%", maxHeight: "100%", @@ -739,13 +740,14 @@ export const EditModal: React.FC = ({ className }) => { // 컴포넌트 위치를 offset만큼 조정 const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; + const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용) const adjustedComponent = { ...component, position: { ...component.position, x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가 }, }; From d09c8e0787a1613117d2dc21fdf671b2d3a3548b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 18:38:16 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/fileController.ts | 94 ++++++ frontend/components/screen/EditModal.tsx | 9 +- .../file-upload/FileUploadComponent.tsx | 276 ++++++++++++++---- .../table-list/TableListComponent.tsx | 29 ++ 4 files changed, 353 insertions(+), 55 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index b1e31e3b..fe3d5cfd 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -341,6 +341,50 @@ export const uploadFiles = async ( }); } + // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 + const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true; + if (isRecordMode && linkedTable && recordId && columnName) { + try { + // 해당 레코드의 모든 첨부파일 조회 + const allFiles = await query( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate + FROM attach_file_info + WHERE target_objid = $1 AND status = 'ACTIVE' + ORDER BY regdate DESC`, + [finalTargetObjid] + ); + + // attachments JSONB 형태로 변환 + const attachmentsJson = allFiles.map((f: any) => ({ + objid: f.objid.toString(), + realFileName: f.real_file_name, + fileSize: Number(f.file_size), + fileExt: f.file_ext, + filePath: f.file_path, + regdate: f.regdate?.toISOString(), + })); + + // 해당 테이블의 attachments 컬럼 업데이트 + // 🔒 멀티테넌시: company_code 필터 추가 + await query( + `UPDATE ${linkedTable} + SET ${columnName} = $1::jsonb, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [JSON.stringify(attachmentsJson), recordId, companyCode] + ); + + console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", { + tableName: linkedTable, + recordId: recordId, + columnName: columnName, + fileCount: attachmentsJson.length, + }); + } catch (updateError) { + // attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리 + console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError); + } + } + res.json({ success: true, message: `${files.length}개 파일 업로드 완료`, @@ -405,6 +449,56 @@ export const deleteFile = async ( ["DELETED", parseInt(objid)] ); + // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 + const targetObjid = fileRecord.target_objid; + if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) { + // targetObjid 파싱: tableName:recordId:columnName 형식 + const parts = targetObjid.split(':'); + if (parts.length >= 3) { + const [tableName, recordId, columnName] = parts; + + try { + // 해당 레코드의 남은 첨부파일 조회 + const remainingFiles = await query( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate + FROM attach_file_info + WHERE target_objid = $1 AND status = 'ACTIVE' + ORDER BY regdate DESC`, + [targetObjid] + ); + + // attachments JSONB 형태로 변환 + const attachmentsJson = remainingFiles.map((f: any) => ({ + objid: f.objid.toString(), + realFileName: f.real_file_name, + fileSize: Number(f.file_size), + fileExt: f.file_ext, + filePath: f.file_path, + regdate: f.regdate?.toISOString(), + })); + + // 해당 테이블의 attachments 컬럼 업데이트 + // 🔒 멀티테넌시: company_code 필터 추가 + await query( + `UPDATE ${tableName} + SET ${columnName} = $1::jsonb, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [JSON.stringify(attachmentsJson), recordId, fileRecord.company_code] + ); + + console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", { + tableName, + recordId, + columnName, + remainingFiles: attachmentsJson.length, + }); + } catch (updateError) { + // attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리 + console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError); + } + } + } + res.json({ success: true, message: "파일이 삭제되었습니다.", diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 3815fc71..9dcb58bf 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -761,12 +761,19 @@ export const EditModal: React.FC = ({ className }) => { }); } + // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 + const enrichedFormData = { + ...(groupData.length > 0 ? groupData[0] : formData), + tableName: screenData.screenInfo?.tableName, // 테이블명 추가 + screenId: modalState.screenId, // 화면 ID 추가 + }; + return ( 0 ? groupData[0] : formData} + formData={enrichedFormData} onFormDataChange={(fieldName, value) => { // 🆕 그룹 데이터가 있으면 처리 if (groupData.length > 0) { diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index 8dda7864..dc77ac93 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -10,6 +10,7 @@ import { apiClient } from "@/lib/api/client"; import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types"; +import { useAuth } from "@/hooks/useAuth"; import { Upload, File, @@ -92,6 +93,9 @@ const FileUploadComponent: React.FC = ({ onDragEnd, onUpdate, }) => { + // 🔑 인증 정보 가져오기 + const { user } = useAuth(); + const [uploadedFiles, setUploadedFiles] = useState([]); const [uploadStatus, setUploadStatus] = useState("idle"); const [dragOver, setDragOver] = useState(false); @@ -102,28 +106,86 @@ const FileUploadComponent: React.FC = ({ const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 + const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); + const recordTableName = formData?.tableName || component.tableName; + const recordId = formData?.id; + const columnName = component.columnName || component.id || 'attachments'; + + // 🔑 레코드 모드용 targetObjid 생성 + const getRecordTargetObjid = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + return `${recordTableName}:${recordId}:${columnName}`; + } + return null; + }, [isRecordMode, recordTableName, recordId, columnName]); + + // 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용) + const getUniqueKey = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + // 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성 + return `fileUpload_${recordTableName}_${recordId}_${component.id}`; + } + // 기본 모드: 컴포넌트 ID만 사용 + return `fileUpload_${component.id}`; + }, [isRecordMode, recordTableName, recordId, component.id]); + + // 🔍 디버깅: 레코드 모드 상태 로깅 + useEffect(() => { + console.log("📎 [FileUploadComponent] 모드 확인:", { + isRecordMode, + recordTableName, + recordId, + columnName, + targetObjid: getRecordTargetObjid(), + uniqueKey: getUniqueKey(), + formDataKeys: formData ? Object.keys(formData) : [], + }); + }, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData]); + + // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 + const prevRecordIdRef = useRef(null); + useEffect(() => { + if (prevRecordIdRef.current !== recordId) { + console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", { + prev: prevRecordIdRef.current, + current: recordId, + isRecordMode, + }); + prevRecordIdRef.current = recordId; + + // 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화 + if (isRecordMode) { + setUploadedFiles([]); + } + } + }, [recordId, isRecordMode]); + // 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원 useEffect(() => { if (!component?.id) return; try { - const backupKey = `fileUpload_${component.id}`; + // 🔑 레코드별 고유 키 사용 + const backupKey = getUniqueKey(); const backupFiles = localStorage.getItem(backupKey); if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", { + uniqueKey: backupKey, componentId: component.id, + recordId: recordId, restoredFiles: parsedFiles.length, files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), }); setUploadedFiles(parsedFiles); - // 전역 상태에도 복원 + // 전역 상태에도 복원 (레코드별 고유 키 사용) if (typeof window !== "undefined") { (window as any).globalFileState = { ...(window as any).globalFileState, - [component.id]: parsedFiles, + [backupKey]: parsedFiles, }; } } @@ -131,7 +193,7 @@ const FileUploadComponent: React.FC = ({ } catch (e) { console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); } - }, [component.id]); // component.id가 변경될 때만 실행 + }, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행 // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 useEffect(() => { @@ -152,12 +214,14 @@ const FileUploadComponent: React.FC = ({ const newFiles = event.detail.files || []; setUploadedFiles(newFiles); - // localStorage 백업 업데이트 + // localStorage 백업 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(newFiles)); console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", { + uniqueKey: backupKey, componentId: component.id, + recordId: recordId, fileCount: newFiles.length, }); } catch (e) { @@ -201,6 +265,16 @@ const FileUploadComponent: React.FC = ({ if (!component?.id) return false; try { + // 🔑 레코드 모드: 해당 행의 파일만 조회 + if (isRecordMode && recordTableName && recordId) { + console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", { + tableName: recordTableName, + recordId: recordId, + columnName: columnName, + targetObjid: getRecordTargetObjid(), + }); + } + // 1. formData에서 screenId 가져오기 let screenId = formData?.screenId; @@ -232,11 +306,13 @@ const FileUploadComponent: React.FC = ({ const params = { screenId, componentId: component.id, - tableName: formData?.tableName || component.tableName, - recordId: formData?.id, - columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용 + tableName: recordTableName || formData?.tableName || component.tableName, + recordId: recordId || formData?.id, + columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName }; + console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params); + const response = await getComponentFiles(params); if (response.success) { @@ -255,11 +331,11 @@ const FileUploadComponent: React.FC = ({ })); - // 🔄 localStorage의 기존 파일과 서버 파일 병합 + // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; + const uniqueKey = getUniqueKey(); try { - const backupKey = `fileUpload_${component.id}`; - const backupFiles = localStorage.getItem(backupKey); + const backupFiles = localStorage.getItem(uniqueKey); if (backupFiles) { const parsedBackupFiles = JSON.parse(backupFiles); @@ -268,7 +344,12 @@ const FileUploadComponent: React.FC = ({ const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid)); finalFiles = [...formattedFiles, ...additionalFiles]; - + console.log("📂 [FileUploadComponent] 파일 병합 완료:", { + uniqueKey, + serverFiles: formattedFiles.length, + localFiles: parsedBackupFiles.length, + finalFiles: finalFiles.length, + }); } } catch (e) { console.warn("파일 병합 중 오류:", e); @@ -276,11 +357,11 @@ const FileUploadComponent: React.FC = ({ setUploadedFiles(finalFiles); - // 전역 상태에도 저장 + // 전역 상태에도 저장 (레코드별 고유 키 사용) if (typeof window !== "undefined") { (window as any).globalFileState = { ...(window as any).globalFileState, - [component.id]: finalFiles, + [uniqueKey]: finalFiles, }; // 🌐 전역 파일 저장소에 등록 (페이지 간 공유용) @@ -288,12 +369,12 @@ const FileUploadComponent: React.FC = ({ uploadPage: window.location.pathname, componentId: component.id, screenId: formData?.screenId, + recordId: recordId, }); - // localStorage 백업도 병합된 파일로 업데이트 + // localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; - localStorage.setItem(backupKey, JSON.stringify(finalFiles)); + localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); } @@ -304,7 +385,7 @@ const FileUploadComponent: React.FC = ({ console.error("파일 조회 오류:", error); } return false; // 기존 로직 사용 - }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]); + }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]); // 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조) useEffect(() => { @@ -316,6 +397,8 @@ const FileUploadComponent: React.FC = ({ componentFiles: componentFiles.length, formData: formData, screenId: formData?.screenId, + tableName: formData?.tableName, // 🔍 테이블명 확인 + recordId: formData?.id, // 🔍 레코드 ID 확인 currentUploadedFiles: uploadedFiles.length, }); @@ -371,9 +454,9 @@ const FileUploadComponent: React.FC = ({ setUploadedFiles(files); setForceUpdate((prev) => prev + 1); - // localStorage 백업도 업데이트 + // localStorage 백업도 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(files)); } catch (e) { console.warn("localStorage 백업 실패:", e); @@ -462,10 +545,10 @@ const FileUploadComponent: React.FC = ({ toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); try { - // targetObjid 생성 - 템플릿 vs 데이터 파일 구분 - const tableName = formData?.tableName || component.tableName || "default_table"; - const recordId = formData?.id; - const columnName = component.columnName || component.id; + // 🔑 레코드 모드 우선 사용 + const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table"; + const effectiveRecordId = recordId || formData?.id; + const effectiveColumnName = columnName; // screenId 추출 (우선순위: formData > URL) let screenId = formData?.screenId; @@ -478,39 +561,56 @@ const FileUploadComponent: React.FC = ({ } let targetObjid; - // 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값 - const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_'); + // 🔑 레코드 모드 판단 개선 + const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_')); - if (isRealRecord && tableName) { - // 실제 데이터 파일 (진짜 레코드 ID가 있을 때만) - targetObjid = `${tableName}:${recordId}:${columnName}`; - console.log("📁 실제 데이터 파일 업로드:", targetObjid); + if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { + // 🎯 레코드 모드: 특정 행에 파일 연결 + targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; + console.log("📁 [레코드 모드] 파일 업로드:", { + targetObjid, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + }); } else if (screenId) { // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) - targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`; + targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`; + console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid); } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; - console.log("📝 기본 파일 업로드:", targetObjid); + console.log("📝 [기본 모드] 파일 업로드:", targetObjid); } // 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리) - const userCompanyCode = (window as any).__user__?.companyCode; + const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; + + console.log("📤 [FileUploadComponent] 파일 업로드 준비:", { + userCompanyCode, + isRecordMode: effectiveIsRecordMode, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + targetObjid, + }); const uploadData = { // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, - linkedTable: formData?.linkedTable || tableName, - recordId: formData?.recordId || recordId || `temp_${component.id}`, - columnName: formData?.columnName || columnName, + linkedTable: formData?.linkedTable || effectiveTableName, + recordId: effectiveRecordId || `temp_${component.id}`, + columnName: effectiveColumnName, isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 // 호환성을 위한 기존 필드들 - tableName: tableName, - fieldName: columnName, + tableName: effectiveTableName, + fieldName: effectiveColumnName, targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가 + // 🆕 레코드 모드 플래그 + isRecordMode: effectiveIsRecordMode, }; @@ -553,9 +653,9 @@ const FileUploadComponent: React.FC = ({ setUploadedFiles(updatedFiles); setUploadStatus("success"); - // localStorage 백업 + // localStorage 백업 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { console.warn("localStorage 백업 실패:", e); @@ -563,9 +663,10 @@ const FileUploadComponent: React.FC = ({ // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 + // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) const globalFileState = (window as any).globalFileState || {}; - globalFileState[component.id] = updatedFiles; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; (window as any).globalFileState = globalFileState; // 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용) @@ -573,12 +674,15 @@ const FileUploadComponent: React.FC = ({ uploadPage: window.location.pathname, componentId: component.id, screenId: formData?.screenId, + recordId: recordId, // 🆕 레코드 ID 추가 }); // 모든 파일 컴포넌트에 동기화 이벤트 발생 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, + uniqueKey: uniqueKey, // 🆕 고유 키 추가 + recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), @@ -612,22 +716,54 @@ const FileUploadComponent: React.FC = ({ console.warn("⚠️ onUpdate 콜백이 없습니다!"); } + // 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트) + if (effectiveIsRecordMode && onFormDataChange) { + // 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환 + const attachmentsData = updatedFiles.map(file => ({ + objid: file.objid, + realFileName: file.realFileName, + fileSize: file.fileSize, + fileExt: file.fileExt, + filePath: file.filePath, + regdate: file.regdate || new Date().toISOString(), + })); + + console.log("📎 [레코드 모드] attachments 컬럼 동기화:", { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + fileCount: attachmentsData.length, + }); + + // onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림 + onFormDataChange({ + [effectiveColumnName]: attachmentsData, + // 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보 + __attachmentsUpdate: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + files: attachmentsData, + } + }); + } + // 그리드 파일 상태 새로고침 이벤트 발생 if (typeof window !== "undefined") { const refreshEvent = new CustomEvent("refreshFileStatus", { detail: { - tableName: tableName, - recordId: recordId, - columnName: columnName, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, targetObjid: targetObjid, fileCount: updatedFiles.length, }, }); window.dispatchEvent(refreshEvent); console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", { - tableName, - recordId, - columnName, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, targetObjid, fileCount: updatedFiles.length, }); @@ -705,9 +841,9 @@ const FileUploadComponent: React.FC = ({ const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId); setUploadedFiles(updatedFiles); - // localStorage 백업 업데이트 + // localStorage 백업 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); @@ -715,15 +851,18 @@ const FileUploadComponent: React.FC = ({ // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 + // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) const globalFileState = (window as any).globalFileState || {}; - globalFileState[component.id] = updatedFiles; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; (window as any).globalFileState = globalFileState; // 모든 파일 컴포넌트에 동기화 이벤트 발생 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, + uniqueKey: uniqueKey, // 🆕 고유 키 추가 + recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), @@ -749,13 +888,42 @@ const FileUploadComponent: React.FC = ({ }); } + // 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후) + if (isRecordMode && onFormDataChange && recordTableName && recordId) { + const attachmentsData = updatedFiles.map(f => ({ + objid: f.objid, + realFileName: f.realFileName, + fileSize: f.fileSize, + fileExt: f.fileExt, + filePath: f.filePath, + regdate: f.regdate || new Date().toISOString(), + })); + + console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", { + tableName: recordTableName, + recordId: recordId, + columnName: columnName, + remainingFiles: attachmentsData.length, + }); + + onFormDataChange({ + [columnName]: attachmentsData, + __attachmentsUpdate: { + tableName: recordTableName, + recordId: recordId, + columnName: columnName, + files: attachmentsData, + } + }); + } + toast.success(`${fileName} 삭제 완료`); } catch (error) { console.error("파일 삭제 오류:", error); toast.error("파일 삭제에 실패했습니다."); } }, - [uploadedFiles, onUpdate, component.id], + [uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey], ); // 대표 이미지 Blob URL 로드 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9fec8fc5..f68b8383 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -3970,6 +3970,35 @@ export const TableListComponent: React.FC = ({ ); } + // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 + if (inputType === "file" || inputType === "attachment" || column.columnName === "attachments") { + // JSONB 배열 또는 JSON 문자열 파싱 + let files: any[] = []; + try { + if (typeof value === "string") { + files = JSON.parse(value); + } else if (Array.isArray(value)) { + files = value; + } + } catch { + // 파싱 실패 시 빈 배열 + } + + if (!files || files.length === 0) { + return -; + } + + // 파일 개수와 아이콘 표시 + const { Paperclip } = require("lucide-react"); + return ( +
+ + {files.length} + +
+ ); + } + // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) if (inputType === "category") { if (!value) return ""; From ae6f022f88cd6c5313e3223f6b223d064c6261c6 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 10 Dec 2025 10:37:33 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(repeat-screen-modal):=20=EB=B3=B5?= =?UTF-8?q?=EC=88=98=20=EC=99=B8=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=A7=80=EC=9B=90=20=EB=B0=8F=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20UI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여러 외부 테이블 데이터를 합산하여 집계 계산 지원 - 집계 설정 전용 모달(AggregationSettingsModal) 추가 - AggregationConfig에 hidden 속성 추가 (연산에만 사용, 표시 제외) - 채번 규칙 API 에러 처리 개선 (조용히 무시, 로그 최소화) --- .../src/services/numberingRuleService.ts | 4 +- frontend/lib/api/client.ts | 6 +- frontend/lib/api/numberingRule.ts | 15 +- .../RepeatScreenModalComponent.tsx | 18 +- .../RepeatScreenModalConfigPanel.tsx | 1695 +++++++++++++++-- .../components/repeat-screen-modal/types.ts | 3 + .../text-input/TextInputComponent.tsx | 16 +- 7 files changed, 1623 insertions(+), 134 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 5272547a..7ba5c47e 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -607,7 +607,9 @@ class NumberingRuleService { } const result = await pool.query(query, params); - if (result.rowCount === 0) return null; + if (result.rowCount === 0) { + return null; + } const rule = result.rows[0]; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index f4a3ccf7..967e43ca 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -317,6 +317,11 @@ apiClient.interceptors.response.use( return Promise.reject(error); } + // 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생) + if (url?.includes("/numbering-rules/") && url?.includes("/preview")) { + return Promise.reject(error); + } + // 다른 에러들은 기존처럼 상세 로그 출력 console.error("API 응답 오류:", { status: status, @@ -324,7 +329,6 @@ apiClient.interceptors.response.use( url: url, data: error.response?.data, message: error.message, - headers: error.config?.headers, }); // 401 에러 처리 diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index b531edce..551a8d25 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -109,11 +109,24 @@ export async function deleteNumberingRule(ruleId: string): Promise> { + // ruleId 유효성 검사 + if (!ruleId || ruleId === "undefined" || ruleId === "null") { + return { success: false, error: "채번 규칙 ID가 설정되지 않았습니다" }; + } + try { const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`); + if (!response.data) { + return { success: false, error: "서버 응답이 비어있습니다" }; + } return response.data; } catch (error: any) { - return { success: false, error: error.message || "코드 미리보기 실패" }; + const errorMessage = + error.response?.data?.error || + error.response?.data?.message || + error.message || + "코드 미리보기 실패"; + return { success: false, error: errorMessage }; } } diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 85e43ce9..1a8012de 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -590,18 +590,24 @@ export function RepeatScreenModalComponent({ if (!hasExternalAggregation) return; - // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 - const tableRowWithExternalSource = contentRows.find( + // contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기 + const tableRowsWithExternalSource = contentRows.filter( (row) => row.type === "table" && row.tableDataSource?.enabled ); - if (!tableRowWithExternalSource) return; + if (tableRowsWithExternalSource.length === 0) return; // 각 카드의 집계 재계산 const updatedCards = groupedCardsData.map((card) => { - const key = `${card._cardId}-${tableRowWithExternalSource.id}`; - // 🆕 v3.7: 삭제된 행은 집계에서 제외 - const externalRows = (extData[key] || []).filter((row) => !row._isDeleted); + // 🆕 v3.11: 모든 외부 테이블 행의 데이터를 합침 + const allExternalRows: any[] = []; + for (const tableRow of tableRowsWithExternalSource) { + const key = `${card._cardId}-${tableRow.id}`; + // 🆕 v3.7: 삭제된 행은 집계에서 제외 + const rows = (extData[key] || []).filter((row) => !row._isDeleted); + allExternalRows.push(...rows); + } + const externalRows = allExternalRows; // 집계 재계산 const newAggregations: Record = {}; diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index 44ee5ce6..1789af9e 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -26,6 +26,8 @@ import { tableManagementApi } from "@/lib/api/tableManagement"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; interface RepeatScreenModalConfigPanelProps { config: Partial; @@ -729,6 +731,1411 @@ function FormulaBuilder({ ); } +// 🆕 집계 설정 전용 모달 +function AggregationSettingsModal({ + open, + onOpenChange, + aggregations, + sourceTable, + allTables, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + aggregations: AggregationConfig[]; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + onSave: (aggregations: AggregationConfig[]) => void; +}) { + // 로컬 상태로 집계 목록 관리 + const [localAggregations, setLocalAggregations] = useState(aggregations); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setLocalAggregations(aggregations); + } + }, [open, aggregations]); + + // 집계 추가 + const addAggregation = (type: "column" | "formula") => { + const newAgg: AggregationConfig = { + sourceType: type, + resultField: `agg_${Date.now()}`, + label: type === "column" ? "새 집계" : "새 가상 집계", + ...(type === "column" ? { type: "sum", sourceField: "", sourceTable: sourceTable } : { formula: "" }), + }; + setLocalAggregations([...localAggregations, newAgg]); + }; + + // 집계 삭제 + const removeAggregation = (index: number) => { + const newAggs = [...localAggregations]; + newAggs.splice(index, 1); + setLocalAggregations(newAggs); + }; + + // 집계 업데이트 + const updateAggregation = (index: number, updates: Partial) => { + const newAggs = [...localAggregations]; + newAggs[index] = { ...newAggs[index], ...updates }; + setLocalAggregations(newAggs); + }; + + // 집계 순서 변경 + const moveAggregation = (index: number, direction: "up" | "down") => { + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= localAggregations.length) return; + + const newAggs = [...localAggregations]; + [newAggs[index], newAggs[newIndex]] = [newAggs[newIndex], newAggs[index]]; + setLocalAggregations(newAggs); + }; + + // 저장 + const handleSave = () => { + onSave(localAggregations); + onOpenChange(false); + }; + + return ( + + + + 집계 설정 + + 그룹 내 데이터의 합계, 개수, 평균 등을 계산합니다. 가상 집계는 다른 집계 결과를 참조하여 연산할 수 있습니다. + + + +
+ +
+ {/* 집계 추가 버튼 */} +
+ + +
+ + {/* 집계 목록 */} + {localAggregations.length === 0 ? ( +
+

집계 설정이 없습니다

+

+ 위의 버튼으로 컬럼 집계 또는 가상 집계를 추가하세요 +

+
+ ) : ( +
+ {localAggregations.map((agg, index) => ( + updateAggregation(index, updates)} + onRemove={() => removeAggregation(index)} + onMove={(direction) => moveAggregation(index, direction)} + /> + ))} +
+ )} +
+
+
+ + + + + +
+
+ ); +} + +// 집계 설정 아이템 (모달용 - 더 넓은 공간 활용) +function AggregationConfigItemModal({ + agg, + index, + totalCount, + sourceTable, + allTables, + existingAggregations, + onUpdate, + onRemove, + onMove, +}: { + agg: AggregationConfig; + index: number; + totalCount: number; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + existingAggregations: AggregationConfig[]; + onUpdate: (updates: Partial) => void; + onRemove: () => void; + onMove: (direction: "up" | "down") => void; +}) { + const [localLabel, setLocalLabel] = useState(agg.label || ""); + const [localResultField, setLocalResultField] = useState(agg.resultField || ""); + const [localFormula, setLocalFormula] = useState(agg.formula || ""); + + useEffect(() => { + setLocalLabel(agg.label || ""); + setLocalResultField(agg.resultField || ""); + setLocalFormula(agg.formula || ""); + }, [agg.label, agg.resultField, agg.formula]); + + // 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지) + const referenceableAggregations = existingAggregations.slice(0, index); + + const currentSourceType = agg.sourceType || "column"; + const isFormula = currentSourceType === "formula"; + + return ( +
+ {/* 헤더 */} +
+
+ {/* 순서 변경 버튼 */} +
+ + +
+ + {isFormula ? "가상" : "집계"} {index + 1} + + {agg.label || "(라벨 없음)"} +
+ +
+ + {/* 집계 타입 선택 */} +
+
+ + +
+ +
+ + setLocalResultField(e.target.value)} + onBlur={() => onUpdate({ resultField: localResultField })} + placeholder="예: total_order_qty" + className="h-9 text-sm" + /> +
+
+ + {/* 컬럼 집계 설정 */} + {!isFormula && ( +
+
+ + +
+ +
+ + onUpdate({ sourceField: value })} + placeholder="컬럼 선택" + /> +
+ +
+ + +
+
+ )} + + {/* 가상 집계 (연산식) 설정 */} + {isFormula && ( +
+
+ +
+ {localFormula || "아래에서 요소를 추가하세요"} +
+
+ + {/* 연산자 */} +
+ +
+ {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} + +
+
+ + {/* 이전 집계 참조 */} + {referenceableAggregations.length > 0 && ( +
+ +
+ {referenceableAggregations.map((refAgg) => ( + + ))} +
+
+ )} + + {/* 테이블 컬럼 집계 */} +
+ + { + const newFormula = localFormula + formulaPart; + setLocalFormula(newFormula); + onUpdate({ formula: newFormula }); + }} + /> +
+
+ )} + + {/* 라벨 및 숨김 설정 */} +
+
+ + setLocalLabel(e.target.value)} + onBlur={() => onUpdate({ label: localLabel })} + placeholder="예: 총수주량" + className="h-9 text-sm" + /> +
+
+ +
+ onUpdate({ hidden: checked })} + className="scale-90" + /> + + {agg.hidden ? "숨김" : "표시"} + +
+
+
+ {agg.hidden && ( +

+ 이 집계는 연산에만 사용되며 레이아웃에서 선택할 수 없습니다. +

+ )} +
+ ); +} + +// 수식에 테이블 컬럼 집계 추가하는 컴포넌트 +function FormulaColumnAggregator({ + sourceTable, + allTables, + onAdd, +}: { + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + onAdd: (formulaPart: string) => void; +}) { + // 데이터 소스 타입: "current" (현재 카드), "external" (외부 테이블 행) + const [dataSourceType, setDataSourceType] = useState<"current" | "external">("current"); + const [selectedTable, setSelectedTable] = useState(sourceTable); + const [selectedColumn, setSelectedColumn] = useState(""); + const [selectedFunction, setSelectedFunction] = useState("SUM"); + + // 데이터 소스 타입 변경 시 테이블 초기화 + useEffect(() => { + if (dataSourceType === "current") { + setSelectedTable(sourceTable); + } + }, [dataSourceType, sourceTable]); + + const handleAdd = () => { + if (!selectedColumn) return; + + // 외부 데이터는 항상 _EXT 접미사 사용 + const funcName = dataSourceType === "external" ? `${selectedFunction}_EXT` : selectedFunction; + const formulaPart = `${funcName}({${selectedColumn}})`; + onAdd(formulaPart); + setSelectedColumn(""); + }; + + return ( +
+ {/* 데이터 소스 선택 */} +
+ + +
+ + {dataSourceType === "external" && ( +

+ 레이아웃의 테이블 행에서 조회한 외부 데이터를 집계합니다 (같은 품목의 다른 수주 등) +

+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ ); +} + +// 🆕 레이아웃 설정 전용 모달 +function LayoutSettingsModal({ + open, + onOpenChange, + contentRows, + allTables, + dataSourceTable, + aggregations, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + contentRows: CardContentRowConfig[]; + allTables: { tableName: string; displayName?: string }[]; + dataSourceTable: string; + aggregations: AggregationConfig[]; + onSave: (contentRows: CardContentRowConfig[]) => void; +}) { + // 로컬 상태로 행 목록 관리 + const [localRows, setLocalRows] = useState(contentRows); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setLocalRows(contentRows); + } + }, [open, contentRows]); + + // 행 추가 + const addRow = (type: CardContentRowConfig["type"]) => { + const newRow: CardContentRowConfig = { + id: `crow-${Date.now()}`, + type, + ...(type === "header" || type === "fields" + ? { columns: [], layout: "horizontal", gap: "16px" } + : {}), + ...(type === "aggregation" + ? { aggregationFields: [], aggregationLayout: "horizontal" } + : {}), + ...(type === "table" + ? { tableColumns: [], showTableHeader: true } + : {}), + }; + setLocalRows([...localRows, newRow]); + }; + + // 행 삭제 + const removeRow = (index: number) => { + const newRows = [...localRows]; + newRows.splice(index, 1); + setLocalRows(newRows); + }; + + // 행 업데이트 + const updateRow = (index: number, updates: Partial) => { + const newRows = [...localRows]; + newRows[index] = { ...newRows[index], ...updates }; + setLocalRows(newRows); + }; + + // 행 순서 변경 + const moveRow = (index: number, direction: "up" | "down") => { + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= localRows.length) return; + + const newRows = [...localRows]; + [newRows[index], newRows[newIndex]] = [newRows[newIndex], newRows[index]]; + setLocalRows(newRows); + }; + + // 컬럼 추가 (header/fields용) + const addColumn = (rowIndex: number) => { + const newRows = [...localRows]; + const newCol: CardColumnConfig = { + id: `col-${Date.now()}`, + field: "", + label: "", + type: "text", + width: "auto", + editable: false, + }; + newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newCol]; + setLocalRows(newRows); + }; + + // 컬럼 삭제 + const removeColumn = (rowIndex: number, colIndex: number) => { + const newRows = [...localRows]; + newRows[rowIndex].columns?.splice(colIndex, 1); + setLocalRows(newRows); + }; + + // 컬럼 업데이트 + const updateColumn = (rowIndex: number, colIndex: number, updates: Partial) => { + const newRows = [...localRows]; + if (newRows[rowIndex].columns) { + newRows[rowIndex].columns![colIndex] = { + ...newRows[rowIndex].columns![colIndex], + ...updates, + }; + } + setLocalRows(newRows); + }; + + // 집계 필드 추가 + const addAggField = (rowIndex: number) => { + const newRows = [...localRows]; + const newAggField: AggregationDisplayConfig = { + aggregationResultField: "", + label: "", + }; + newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField]; + setLocalRows(newRows); + }; + + // 집계 필드 삭제 + const removeAggField = (rowIndex: number, fieldIndex: number) => { + const newRows = [...localRows]; + newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1); + setLocalRows(newRows); + }; + + // 집계 필드 업데이트 + const updateAggField = (rowIndex: number, fieldIndex: number, updates: Partial) => { + const newRows = [...localRows]; + if (newRows[rowIndex].aggregationFields) { + newRows[rowIndex].aggregationFields![fieldIndex] = { + ...newRows[rowIndex].aggregationFields![fieldIndex], + ...updates, + }; + } + setLocalRows(newRows); + }; + + // 집계 필드 순서 변경 + const moveAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => { + const newRows = [...localRows]; + const fields = newRows[rowIndex].aggregationFields; + if (!fields) return; + + const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1; + if (newIndex < 0 || newIndex >= fields.length) return; + + [fields[fieldIndex], fields[newIndex]] = [fields[newIndex], fields[fieldIndex]]; + setLocalRows(newRows); + }; + + // 테이블 컬럼 추가 + const addTableColumn = (rowIndex: number) => { + const newRows = [...localRows]; + const newCol: TableColumnConfig = { + id: `tcol-${Date.now()}`, + field: "", + label: "", + type: "text", + editable: false, + }; + newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol]; + setLocalRows(newRows); + }; + + // 테이블 컬럼 삭제 + const removeTableColumn = (rowIndex: number, colIndex: number) => { + const newRows = [...localRows]; + newRows[rowIndex].tableColumns?.splice(colIndex, 1); + setLocalRows(newRows); + }; + + // 테이블 컬럼 업데이트 + const updateTableColumn = (rowIndex: number, colIndex: number, updates: Partial) => { + const newRows = [...localRows]; + if (newRows[rowIndex].tableColumns) { + newRows[rowIndex].tableColumns![colIndex] = { + ...newRows[rowIndex].tableColumns![colIndex], + ...updates, + }; + } + setLocalRows(newRows); + }; + + // 테이블 컬럼 순서 변경 + const moveTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => { + const newRows = [...localRows]; + const cols = newRows[rowIndex].tableColumns; + if (!cols) return; + + const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1; + if (newIndex < 0 || newIndex >= cols.length) return; + + [cols[colIndex], cols[newIndex]] = [cols[newIndex], cols[colIndex]]; + setLocalRows(newRows); + }; + + // 저장 + const handleSave = () => { + onSave(localRows); + onOpenChange(false); + }; + + // 행 타입별 색상 + const getRowTypeColor = (type: CardContentRowConfig["type"]) => { + switch (type) { + case "header": return "bg-blue-100 border-blue-300"; + case "aggregation": return "bg-orange-100 border-orange-300"; + case "table": return "bg-green-100 border-green-300"; + case "fields": return "bg-purple-100 border-purple-300"; + default: return "bg-gray-100 border-gray-300"; + } + }; + + const getRowTypeLabel = (type: CardContentRowConfig["type"]) => { + switch (type) { + case "header": return "헤더"; + case "aggregation": return "집계"; + case "table": return "테이블"; + case "fields": return "필드"; + default: return type; + } + }; + + return ( + + + + 레이아웃 설정 + + 카드 내부의 행(헤더, 집계, 테이블, 필드)을 구성합니다. 각 행은 순서를 변경할 수 있습니다. + + + +
+ +
+ {/* 행 추가 버튼 */} +
+ + + + +
+ + {/* 행 목록 */} + {localRows.length === 0 ? ( +
+

레이아웃 행이 없습니다

+

+ 위의 버튼으로 헤더, 집계, 테이블, 필드 행을 추가하세요 +

+
+ ) : ( +
+ {localRows.map((row, index) => ( + updateRow(index, updates)} + onRemoveRow={() => removeRow(index)} + onMoveRow={(direction) => moveRow(index, direction)} + onAddColumn={() => addColumn(index)} + onRemoveColumn={(colIndex) => removeColumn(index, colIndex)} + onUpdateColumn={(colIndex, updates) => updateColumn(index, colIndex, updates)} + onAddAggField={() => addAggField(index)} + onRemoveAggField={(fieldIndex) => removeAggField(index, fieldIndex)} + onUpdateAggField={(fieldIndex, updates) => updateAggField(index, fieldIndex, updates)} + onMoveAggField={(fieldIndex, direction) => moveAggField(index, fieldIndex, direction)} + onAddTableColumn={() => addTableColumn(index)} + onRemoveTableColumn={(colIndex) => removeTableColumn(index, colIndex)} + onUpdateTableColumn={(colIndex, updates) => updateTableColumn(index, colIndex, updates)} + onMoveTableColumn={(colIndex, direction) => moveTableColumn(index, colIndex, direction)} + getRowTypeColor={getRowTypeColor} + getRowTypeLabel={getRowTypeLabel} + /> + ))} +
+ )} +
+
+
+ + + + + +
+
+ ); +} + +// 레이아웃 행 설정 (모달용) +function LayoutRowConfigModal({ + row, + rowIndex, + totalRows, + allTables, + dataSourceTable, + aggregations, + onUpdateRow, + onRemoveRow, + onMoveRow, + onAddColumn, + onRemoveColumn, + onUpdateColumn, + onAddAggField, + onRemoveAggField, + onUpdateAggField, + onMoveAggField, + onAddTableColumn, + onRemoveTableColumn, + onUpdateTableColumn, + onMoveTableColumn, + getRowTypeColor, + getRowTypeLabel, +}: { + row: CardContentRowConfig; + rowIndex: number; + totalRows: number; + allTables: { tableName: string; displayName?: string }[]; + dataSourceTable: string; + aggregations: AggregationConfig[]; + onUpdateRow: (updates: Partial) => void; + onRemoveRow: () => void; + onMoveRow: (direction: "up" | "down") => void; + onAddColumn: () => void; + onRemoveColumn: (colIndex: number) => void; + onUpdateColumn: (colIndex: number, updates: Partial) => void; + onAddAggField: () => void; + onRemoveAggField: (fieldIndex: number) => void; + onUpdateAggField: (fieldIndex: number, updates: Partial) => void; + onMoveAggField: (fieldIndex: number, direction: "up" | "down") => void; + onAddTableColumn: () => void; + onRemoveTableColumn: (colIndex: number) => void; + onUpdateTableColumn: (colIndex: number, updates: Partial) => void; + onMoveTableColumn: (colIndex: number, direction: "up" | "down") => void; + getRowTypeColor: (type: CardContentRowConfig["type"]) => string; + getRowTypeLabel: (type: CardContentRowConfig["type"]) => string; +}) { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
+ {/* 행 헤더 */} +
+
+ {/* 순서 변경 버튼 */} +
+ + +
+ {getRowTypeLabel(row.type)} {rowIndex + 1} + + {row.type === "header" || row.type === "fields" + ? `${(row.columns || []).length}개 컬럼` + : row.type === "aggregation" + ? `${(row.aggregationFields || []).length}개 필드` + : row.type === "table" + ? `${(row.tableColumns || []).length}개 컬럼` + : ""} + +
+
+ + +
+
+ + {/* 행 내용 */} + {isExpanded && ( +
+ {/* 헤더/필드 타입 */} + {(row.type === "header" || row.type === "fields") && ( +
+
+
+ + +
+
+ + +
+
+ + onUpdateRow({ gap: e.target.value })} + placeholder="16px" + className="h-8 text-xs" + /> +
+
+ + {/* 컬럼 목록 */} +
+
+ + +
+ {(row.columns || []).map((col, colIndex) => ( +
+
+ 컬럼 {colIndex + 1} + +
+
+
+ + onUpdateColumn(colIndex, { field: value })} + placeholder="필드 선택" + /> +
+
+ + onUpdateColumn(colIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 text-[10px]" + /> +
+
+ + +
+
+ + onUpdateColumn(colIndex, { width: e.target.value })} + placeholder="auto" + className="h-6 text-[10px]" + /> +
+
+
+ ))} +
+
+ )} + + {/* 집계 타입 */} + {row.type === "aggregation" && ( +
+
+
+ + +
+ {row.aggregationLayout === "grid" && ( +
+ + +
+ )} +
+ + {/* 집계 필드 목록 */} +
+
+ + +
+ {aggregations.filter(a => !a.hidden).length === 0 && ( +

+ 그룹 탭에서 먼저 집계를 설정해주세요 +

+ )} + {(row.aggregationFields || []).map((field, fieldIndex) => ( +
+
+
+ + + 집계 {fieldIndex + 1} +
+ +
+
+
+ + +
+
+ + onUpdateAggField(fieldIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 text-[10px]" + /> +
+
+ + +
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* 테이블 타입 */} + {row.type === "table" && ( +
+
+
+ + onUpdateRow({ tableTitle: e.target.value })} + placeholder="테이블 제목" + className="h-8 text-xs" + /> +
+
+ +
+ onUpdateRow({ showTableHeader: checked })} + className="scale-90" + /> + {row.showTableHeader !== false ? "표시" : "숨김"} +
+
+
+ + onUpdateRow({ tableMaxHeight: e.target.value })} + placeholder="예: 300px" + className="h-8 text-xs" + /> +
+
+ + {/* 외부 데이터 소스 설정 */} +
+
+ + onUpdateRow({ + tableDataSource: { ...row.tableDataSource, enabled: checked, sourceTable: "", joinConditions: [] } + })} + className="scale-90" + /> +
+ {row.tableDataSource?.enabled && ( +
+
+ + +
+
+ )} +
+ + {/* 테이블 컬럼 목록 */} +
+
+ + +
+ {(row.tableColumns || []).map((col, colIndex) => ( +
+
+
+ + + 컬럼 {colIndex + 1} +
+ +
+
+
+ + onUpdateTableColumn(colIndex, { field: value })} + placeholder="필드 선택" + /> +
+
+ + onUpdateTableColumn(colIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 text-[10px]" + /> +
+
+ + +
+
+ +
+ onUpdateTableColumn(colIndex, { editable: checked })} + className="scale-75" + /> + {col.editable ? "예" : "아니오"} +
+
+
+
+ ))} +
+
+ )} +
+ )} +
+ ); +} + // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) // 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 function AggregationConfigItem({ @@ -1194,6 +2601,12 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]); + // 집계 설정 모달 상태 + const [aggregationModalOpen, setAggregationModalOpen] = useState(false); + + // 레이아웃 설정 모달 상태 + const [layoutModalOpen, setLayoutModalOpen] = useState(false); + // 탭 상태 유지 (모듈 레벨 변수와 동기화) const [activeTab, setActiveTab] = useState(persistedActiveTab); @@ -1536,6 +2949,21 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // 행(Row) 순서 변경 + const moveContentRow = (rowIndex: number, direction: "up" | "down") => { + const rows = localConfig.contentRows || []; + if (rows.length <= 1) return; + + const newIndex = direction === "up" ? rowIndex - 1 : rowIndex + 1; + if (newIndex < 0 || newIndex >= rows.length) return; + + // 행 위치 교환 + const newRows = [...rows]; + [newRows[rowIndex], newRows[newIndex]] = [newRows[newIndex], newRows[rowIndex]]; + + updateConfig({ contentRows: newRows }); + }; + // === (레거시) Simple 모드 행/컬럼 관련 함수 === const addRow = () => { const newRow: CardRowConfig = { @@ -1760,48 +3188,42 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
-
- - -
+
-

- 컬럼 집계: 테이블 컬럼의 합계/개수 등 | 가상 집계: 연산식으로 계산 -

- - {(localConfig.grouping?.aggregations || []).map((agg, index) => ( - updateAggregation(index, updates)} - onRemove={() => removeAggregation(index)} - /> - ))} - - {(localConfig.grouping?.aggregations || []).length === 0 && ( + {/* 현재 집계 목록 요약 */} + {(localConfig.grouping?.aggregations || []).length > 0 ? ( +
+ {(localConfig.grouping?.aggregations || []).map((agg, index) => ( +
+ + {agg.hidden && [숨김]} + {agg.label || agg.resultField} + + + {agg.sourceType === "formula" ? "가상" : agg.type?.toUpperCase() || "SUM"} + +
+ ))} +
+ ) : (

집계 설정이 없습니다

@@ -1814,86 +3236,78 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM {/* === 레이아웃 설정 탭 === */} -
- {/* 행 추가 버튼들 */} -
-

행 추가

-
- -