diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 66e20ccd..00727f1d 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -29,6 +29,7 @@ export class EntityJoinController { screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) + excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -125,6 +126,19 @@ export class EntityJoinController { } } + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + let parsedExcludeFilter: any = undefined; + if (excludeFilter) { + try { + parsedExcludeFilter = + typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter; + logger.info("제외 필터 파싱 완료:", parsedExcludeFilter); + } catch (error) { + logger.warn("제외 필터 파싱 오류:", error); + parsedExcludeFilter = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -141,6 +155,7 @@ export class EntityJoinController { additionalJoinColumns: parsedAdditionalJoinColumns, screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 + excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 } ); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 8e01903b..781a9498 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2462,6 +2462,14 @@ export class TableManagementService { }>; screenEntityConfigs?: Record; // 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } ): Promise { const startTime = Date.now(); @@ -2716,6 +2724,44 @@ export class TableManagementService { } } + // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) + if (options.excludeFilter && options.excludeFilter.enabled) { + const { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + } = options.excludeFilter; + + if (referenceTable && referenceColumn && sourceColumn) { + // 서브쿼리로 이미 존재하는 데이터 제외 + let excludeSubquery = `main."${sourceColumn}" NOT IN ( + SELECT "${referenceColumn}" FROM "${referenceTable}" + WHERE "${referenceColumn}" IS NOT NULL`; + + // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) + if (filterColumn && filterValue !== undefined && filterValue !== null) { + excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; + } + + excludeSubquery += ")"; + + whereClause = whereClause + ? `${whereClause} AND ${excludeSubquery}` + : excludeSubquery; + + logger.info(`🚫 제외 필터 적용 (Entity 조인):`, { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + excludeSubquery, + }); + } + } + // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 21b9c749..0cb6635d 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, Save, Edit2, Trash2 } from "lucide-react"; import { toast } from "sonner"; -import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; +import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRulePreview } from "./NumberingRulePreview"; import { @@ -47,6 +47,10 @@ export const NumberingRuleDesigner: React.FC = ({ const [rightTitle, setRightTitle] = useState("규칙 편집"); const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); + + // 구분자 관련 상태 + const [separatorType, setSeparatorType] = useState("-"); + const [customSeparator, setCustomSeparator] = useState(""); useEffect(() => { loadRules(); @@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); + // currentRule이 변경될 때 구분자 상태 동기화 + useEffect(() => { + if (currentRule) { + const sep = currentRule.separator ?? "-"; + // 빈 문자열이면 "none" + if (sep === "") { + setSeparatorType("none"); + setCustomSeparator(""); + return; + } + // 미리 정의된 구분자인지 확인 (none, custom 제외) + const predefinedOption = SEPARATOR_OPTIONS.find( + opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + ); + if (predefinedOption) { + setSeparatorType(predefinedOption.value); + setCustomSeparator(""); + } else { + // 직접 입력된 구분자 + setSeparatorType("custom"); + setCustomSeparator(sep); + } + } + }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + + // 구분자 변경 핸들러 + const handleSeparatorChange = useCallback((type: SeparatorType) => { + setSeparatorType(type); + if (type !== "custom") { + const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); + const newSeparator = option?.displayValue ?? ""; + setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); + setCustomSeparator(""); + } + }, []); + + // 직접 입력 구분자 변경 핸들러 + const handleCustomSeparatorChange = useCallback((value: string) => { + // 최대 2자 제한 + const trimmedValue = value.slice(0, 2); + setCustomSeparator(trimmedValue); + setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + }, []); + const handleAddPart = useCallback(() => { if (!currentRule) return; @@ -373,7 +421,44 @@ export const NumberingRuleDesigner: React.FC = ({ - {/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */} + {/* 두 번째 줄: 구분자 설정 */} +
+
+ + +
+ {separatorType === "custom" && ( +
+ + handleCustomSeparatorChange(e.target.value)} + className="h-9" + placeholder="최대 2자" + maxLength={2} + /> +
+ )} +

+ 규칙 사이에 들어갈 문자입니다 +

+
+ + {/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */} {currentTableName && (
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index cde9086c..024f7ac7 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -304,7 +304,24 @@ export const EditModal: React.FC = ({ className }) => { }; // 저장 버튼 클릭 시 - UPDATE 액션 실행 - const handleSave = async () => { + const handleSave = async (saveData?: any) => { + // universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵 + if (saveData?._saveCompleted) { + console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵"); + + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } + } + + handleClose(); + return; + } + if (!screenData?.screenInfo?.tableName) { toast.error("테이블 정보가 없습니다."); return; diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index a84f3355..a3206df9 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -69,6 +69,14 @@ export const entityJoinApi = { }>; screenEntityConfigs?: Record; // 🎯 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } = {}, ): Promise => { // 🔒 멀티테넌시: company_code 자동 필터링 활성화 @@ -90,6 +98,7 @@ export const entityJoinApi = { screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 + excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 }, }); return response.data.data; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index c0e0c87e..8609623b 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -170,8 +170,9 @@ export const DynamicComponentRenderer: React.FC = } }; - // 🆕 disabledFields 체크 - const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly; + // 🆕 disabledFields 체크 + readonly 체크 + const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled; + const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly; return ( = placeholder={component.componentConfig?.placeholder || "선택하세요"} required={(component as any).required} disabled={isFieldDisabled} + readonly={isFieldReadonly} className="w-full" /> ); diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index 1c5920f0..7a115ea3 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -42,10 +42,26 @@ export function AutocompleteSearchInputComponent({ // config prop 우선, 없으면 개별 prop 사용 const tableName = config?.tableName || propTableName || ""; const displayField = config?.displayField || propDisplayField || ""; + const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드 + const displaySeparator = config?.displaySeparator || " → "; // 구분자 const valueField = config?.valueField || propValueField || ""; - const searchFields = config?.searchFields || propSearchFields || [displayField]; + const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용 const placeholder = config?.placeholder || propPlaceholder || "검색..."; + // 다중 필드 값을 조합하여 표시 문자열 생성 + const getDisplayValue = (item: EntitySearchResult): string => { + if (displayFields.length > 1) { + // 여러 필드를 구분자로 조합 + const values = displayFields + .map((field) => item[field]) + .filter((v) => v !== null && v !== undefined && v !== "") + .map((v) => String(v)); + return values.join(displaySeparator); + } + // 단일 필드 + return item[displayField] || ""; + }; + const [inputValue, setInputValue] = useState(""); const [isOpen, setIsOpen] = useState(false); const [selectedData, setSelectedData] = useState(null); @@ -115,7 +131,7 @@ export function AutocompleteSearchInputComponent({ const handleSelect = (item: EntitySearchResult) => { setSelectedData(item); - setInputValue(item[displayField] || ""); + setInputValue(getDisplayValue(item)); console.log("🔍 AutocompleteSearchInput handleSelect:", { item, @@ -239,7 +255,7 @@ export function AutocompleteSearchInputComponent({ onClick={() => handleSelect(item)} className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm" > -
{item[displayField]}
+
{getDisplayValue(item)}
))}
diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx index d2290c2f..bb0b8175 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx @@ -184,52 +184,118 @@ export function AutocompleteSearchInputConfigPanel({ - {/* 2. 표시 필드 선택 */} + {/* 2. 표시 필드 선택 (다중 선택 가능) */}
- - - - - - - - - - 필드를 찾을 수 없습니다. - - {sourceTableColumns.map((column) => ( - { - updateConfig({ displayField: column.columnName }); - setOpenDisplayFieldCombo(false); + +
+ {/* 선택된 필드 표시 */} + {(localConfig.displayFields && localConfig.displayFields.length > 0) ? ( +
+ {localConfig.displayFields.map((fieldName) => { + const col = sourceTableColumns.find((c) => c.columnName === fieldName); + return ( + + {col?.displayName || fieldName} + + + ); + })} +
+ ) : ( +
+ 아래에서 표시할 필드를 선택하세요 +
+ )} + + {/* 필드 선택 드롭다운 */} + + + + + + + + + 필드를 찾을 수 없습니다. + + {sourceTableColumns.map((column) => { + const isSelected = localConfig.displayFields?.includes(column.columnName); + return ( + { + const currentFields = localConfig.displayFields || []; + let newFields: string[]; + if (isSelected) { + newFields = currentFields.filter((f) => f !== column.columnName); + } else { + newFields = [...currentFields, column.columnName]; + } + updateConfig({ + displayFields: newFields, + displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로 + }); + }} + className="text-xs sm:text-sm" + > + +
+ {column.displayName || column.columnName} + {column.displayName && {column.columnName}} +
+
+ ); + })} +
+
+
+
+
+ + {/* 구분자 설정 */} + {localConfig.displayFields && localConfig.displayFields.length > 1 && ( +
+ + updateConfig({ displaySeparator: e.target.value })} + placeholder=" → " + className="h-7 w-20 text-xs text-center" + /> + + 미리보기: {localConfig.displayFields.map((f) => { + const col = sourceTableColumns.find((c) => c.columnName === f); + return col?.displayName || f; + }).join(localConfig.displaySeparator || " → ")} + +
+ )} +
{/* 3. 저장 대상 테이블 선택 */} @@ -419,7 +485,9 @@ export function AutocompleteSearchInputConfigPanel({ 외부 테이블: {localConfig.tableName}

- 표시 필드: {localConfig.displayField} + 표시 필드: {localConfig.displayFields?.length + ? localConfig.displayFields.join(localConfig.displaySeparator || " → ") + : localConfig.displayField}

저장 테이블: {localConfig.targetTable} diff --git a/frontend/lib/registry/components/autocomplete-search-input/types.ts b/frontend/lib/registry/components/autocomplete-search-input/types.ts index 85101e89..ea1c3734 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/types.ts +++ b/frontend/lib/registry/components/autocomplete-search-input/types.ts @@ -29,5 +29,8 @@ export interface AutocompleteSearchInputConfig { fieldMappings?: FieldMapping[]; // 매핑할 필드 목록 // 저장 대상 테이블 (간소화 버전) targetTable?: string; + // 🆕 다중 표시 필드 설정 (여러 컬럼 조합) + displayFields?: string[]; // 여러 컬럼을 조합하여 표시 + displaySeparator?: string; // 구분자 (기본값: " - ") } diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 0bf8bea2..5816940a 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -663,9 +663,29 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + // 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) + let effectiveSelectedRowsData = selectedRowsData; + if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) { + try { + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + const modalData = dataRegistry[effectiveTableName]; + if (modalData && modalData.length > 0) { + effectiveSelectedRowsData = modalData; + console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { + tableName: effectiveTableName, + count: modalData.length, + data: modalData, + }); + } + } catch (error) { + console.warn("modalDataStore 접근 실패:", error); + } + } + // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = - (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); + (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); if (processedConfig.action.type === "delete" && !hasDataToDelete) { toast.warning("삭제할 항목을 먼저 선택해주세요."); @@ -724,9 +744,9 @@ export const ButtonPrimaryComponent: React.FC = ({ onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) - // 테이블 선택된 행 정보 추가 + // 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선) selectedRows, - selectedRowsData, + selectedRowsData: effectiveSelectedRowsData, // 테이블 정렬 정보 추가 sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 746e2c2d..2a5d45e4 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer"; // 🆕 화면 임베딩 및 분할 패널 컴포넌트 import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달) +// 🆕 범용 폼 모달 컴포넌트 +import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 6302e7f9..3a5b43dd 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -193,7 +193,18 @@ export function ModalRepeaterTableComponent({ // ✅ value는 formData[columnName] 우선, 없으면 prop 사용 const columnName = component?.columnName; - const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; + const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; + + // 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해) + const [localValue, setLocalValue] = useState(externalValue); + + // 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화 + useEffect(() => { + // 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화 + if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) { + setLocalValue(externalValue); + } + }, [externalValue]); // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용) const handleChange = (newData: any[]) => { @@ -249,6 +260,9 @@ export function ModalRepeaterTableComponent({ } } + // 🆕 내부 상태 즉시 업데이트 (UI 즉시 반영) - 일괄 적용된 데이터로 업데이트 + setLocalValue(processedData); + // 기존 onChange 콜백 호출 (호환성) const externalOnChange = componentConfig?.onChange || propOnChange; if (externalOnChange) { @@ -321,7 +335,7 @@ export function ModalRepeaterTableComponent({ const handleSaveRequest = async (event: Event) => { const componentKey = columnName || component?.id || "modal_repeater_data"; - if (value.length === 0) { + if (localValue.length === 0) { console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음"); return; } @@ -332,7 +346,7 @@ export function ModalRepeaterTableComponent({ .filter(col => col.mapping?.type === "source" && col.mapping?.sourceField) .map(col => col.field); - const filteredData = value.map((item: any) => { + const filteredData = localValue.map((item: any) => { const filtered: Record = {}; Object.keys(item).forEach((key) => { @@ -389,16 +403,16 @@ export function ModalRepeaterTableComponent({ return () => { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [value, columnName, component?.id, onFormDataChange, targetTable]); + }, [localValue, columnName, component?.id, onFormDataChange, targetTable]); const { calculateRow, calculateAll } = useCalculation(calculationRules); // 초기 데이터에 계산 필드 적용 useEffect(() => { - if (value.length > 0 && calculationRules.length > 0) { - const calculated = calculateAll(value); + if (localValue.length > 0 && calculationRules.length > 0) { + const calculated = calculateAll(localValue); // 값이 실제로 변경된 경우만 업데이트 - if (JSON.stringify(calculated) !== JSON.stringify(value)) { + if (JSON.stringify(calculated) !== JSON.stringify(localValue)) { handleChange(calculated); } } @@ -506,7 +520,7 @@ export function ModalRepeaterTableComponent({ const calculatedItems = calculateAll(mappedItems); // 기존 데이터에 추가 - const newData = [...value, ...calculatedItems]; + const newData = [...localValue, ...calculatedItems]; console.log("✅ 최종 데이터:", newData.length, "개 항목"); // ✅ 통합 onChange 호출 (formData 반영 포함) @@ -518,7 +532,7 @@ export function ModalRepeaterTableComponent({ const calculatedRow = calculateRow(newRow); // 데이터 업데이트 - const newData = [...value]; + const newData = [...localValue]; newData[index] = calculatedRow; // ✅ 통합 onChange 호출 (formData 반영 포함) @@ -526,7 +540,7 @@ export function ModalRepeaterTableComponent({ }; const handleRowDelete = (index: number) => { - const newData = value.filter((_, i) => i !== index); + const newData = localValue.filter((_, i) => i !== index); // ✅ 통합 onChange 호출 (formData 반영 포함) handleChange(newData); @@ -543,7 +557,7 @@ export function ModalRepeaterTableComponent({ {/* 추가 버튼 */}

- {value.length > 0 && `${value.length}개 항목`} + {localValue.length > 0 && `${localValue.length}개 항목`}
)} {config.rightPanel?.showDeleteButton && ( - )}
@@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC { + const displayColumns = config.rightPanel?.displayColumns || []; + const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시 + const pkColumn = getPrimaryKeyColumn(); + const allSelected = filteredRightData.length > 0 && + filteredRightData.every((item) => selectedRightItems.has(item[pkColumn])); + const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn])); + + return ( +
+ + + + {showCheckbox && ( + + { + if (el) { + (el as any).indeterminate = someSelected && !allSelected; + } + }} + onCheckedChange={handleSelectAll} + /> + + )} + {displayColumns.map((col, idx) => ( + + {col.label || col.name} + + ))} + {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( + 작업 + )} + + + + {filteredRightData.length === 0 ? ( + + + 등록된 항목이 없습니다 + + + ) : ( + filteredRightData.map((item, index) => { + const itemId = item[pkColumn]; + return ( + + {showCheckbox && ( + + handleSelectItem(itemId, !!checked)} + /> + + )} + {displayColumns.map((col, colIdx) => ( + + {formatValue(item[col.name], col.format)} + + ))} + {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( + +
+ {config.rightPanel?.showEditButton && ( + + )} + {config.rightPanel?.showDeleteButton && ( + + )} +
+
+ )} +
+ ); + }) + )} +
+
+
+ ); + }; + + // 액션 버튼 렌더링 + const renderActionButtons = () => { + const actionButtons = config.rightPanel?.actionButtons; + if (!actionButtons || actionButtons.length === 0) return null; + + return ( +
+ {actionButtons.map((btn) => ( + + ))} +
+ ); + }; + // 디자인 모드 렌더링 if (isDesignMode) { return ( @@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC
-

- {selectedLeftItem - ? config.leftPanel?.displayColumns?.[0] - ? selectedLeftItem[config.leftPanel.displayColumns[0].name] - : config.rightPanel?.title || "상세" - : config.rightPanel?.title || "상세"} -

-
+
+

+ {selectedLeftItem + ? config.leftPanel?.displayColumns?.[0] + ? selectedLeftItem[config.leftPanel.displayColumns[0].name] + : config.rightPanel?.title || "상세" + : config.rightPanel?.title || "상세"} +

{selectedLeftItem && ( - {rightData.length}명 + ({rightData.length}건) )} - {config.rightPanel?.showAddButton && selectedLeftItem && ( + {/* 선택된 항목 수 표시 */} + {selectedRightItems.size > 0 && ( + + {selectedRightItems.size}개 선택됨 + + )} +
+
+ {/* 복수 액션 버튼 (actionButtons 설정 시) */} + {selectedLeftItem && renderActionButtons()} + + {/* 기존 단일 추가 버튼 (하위 호환성) */} + {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
- ) : filteredRightData.length === 0 ? ( -
- - 등록된 항목이 없습니다 -
) : ( -
- {filteredRightData.map((item, index) => renderRightCard(item, index))} -
+ <> + {/* displayMode에 따라 카드 또는 테이블 렌더링 */} + {config.rightPanel?.displayMode === "table" ? ( + renderRightTable() + ) : filteredRightData.length === 0 ? ( +
+ + 등록된 항목이 없습니다 +
+ ) : ( +
+ {filteredRightData.map((item, index) => renderRightCard(item, index))} +
+ )} + )}
+ + {/* 삭제 확인 다이얼로그 */} + + + + 삭제 확인 + + {isBulkDelete + ? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?` + : "이 항목을 삭제하시겠습니까?"} +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
); }; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index db3638cb..da520d92 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateDisplayColumn("left", index, "name", value)} placeholder="컬럼 선택" /> +
+ + updateDisplayColumn("left", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
updateConfig("rightPanel.displayMode", value)} + > + + + + + 카드형 + 테이블형 + + +

+ 카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시 +

+
+ + {/* 카드 모드 전용 옵션 */} + {(config.rightPanel?.displayMode || "card") === "card" && ( +
+
+ +

라벨: 값 형식으로 표시

+
+ updateConfig("rightPanel.showLabels", checked)} + /> +
+ )} + + {/* 체크박스 표시 */} +
+
+ +

항목 선택 기능 활성화

+
+ updateConfig("rightPanel.showCheckbox", checked)} + /> +
+ + {/* 수정/삭제 버튼 */} +
+ +
+
+ + updateConfig("rightPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("rightPanel.showDeleteButton", checked)} + /> +
+
+
+ + {/* 수정 모달 화면 (수정 버튼 활성화 시) */} + {config.rightPanel?.showEditButton && ( +
+ + updateConfig("rightPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)" + open={false} + onOpenChange={() => {}} + /> +

+ 미선택 시 추가 모달 화면을 수정용으로 사용 +

+
+ )} + + {/* 기본키 컬럼 */} +
+ + updateConfig("rightPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

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

+
+ + {/* 복수 액션 버튼 설정 */} +
+
+ + +
+

+ 복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다 +

+
+ {(config.rightPanel?.actionButtons || []).map((btn, index) => ( +
+
+ 버튼 {index + 1} + +
+
+ + { + const current = [...(config.rightPanel?.actionButtons || [])]; + current[index] = { ...current[index], label: e.target.value }; + updateConfig("rightPanel.actionButtons", current); + }} + placeholder="버튼 라벨" + className="h-8 text-xs" + /> +
+
+ + +
+
+ + +
+
+ + +
+ {btn.action === "add" && ( +
+ + { + const current = [...(config.rightPanel?.actionButtons || [])]; + current[index] = { ...current[index], modalScreenId: value }; + updateConfig("rightPanel.actionButtons", current); + }} + placeholder="모달 화면 선택" + open={false} + onOpenChange={() => {}} + /> +
+ )} +
+ ))} + {(config.rightPanel?.actionButtons || []).length === 0 && ( +
+ 액션 버튼을 추가하세요 (선택사항) +
+ )} +
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index a5813600..872563df 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -22,6 +22,18 @@ export interface ColumnConfig { }; } +/** + * 액션 버튼 설정 + */ +export interface ActionButtonConfig { + id: string; // 고유 ID + label: string; // 버튼 라벨 + variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2") + modalScreenId?: number; // 연결할 모달 화면 ID + action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형 +} + /** * 데이터 전달 필드 설정 */ @@ -70,12 +82,17 @@ export interface RightPanelConfig { searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 - showAddButton?: boolean; // 추가 버튼 표시 - addButtonLabel?: string; // 추가 버튼 라벨 - addModalScreenId?: number; // 추가 모달 화면 ID - showEditButton?: boolean; // 수정 버튼 표시 - showDeleteButton?: boolean; // 삭제 버튼 표시 - displayMode?: "card" | "list"; // 표시 모드 + showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) + addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) + addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성) + showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성) + editModalScreenId?: number; // 수정 모달 화면 ID + displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형) + showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식) + showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용) + actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 + primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id) emptyMessage?: string; // 데이터 없을 때 메시지 } @@ -110,4 +127,3 @@ export interface SplitPanelLayout2Config { // 동작 설정 autoLoad?: boolean; // 자동 데이터 로드 } - diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 261fa108..4f78ed23 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -179,6 +179,7 @@ export const TableListComponent: React.FC = ({ config, className, style, + formData: propFormData, // 🆕 부모에서 전달받은 formData onFormDataChange, componentConfig, onSelectedRowsChange, @@ -1183,13 +1184,74 @@ export const TableListComponent: React.FC = ({ referenceTable: col.additionalJoinInfo!.referenceTable, })); - // console.log("🔍 [TableList] API 호출 시작", { - // tableName: tableConfig.selectedTable, - // page, - // pageSize, - // sortBy, - // sortOrder, - // }); + // 🎯 화면별 엔티티 표시 설정 수집 + const screenEntityConfigs: Record = {}; + (tableConfig.columns || []) + .filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0) + .forEach((col) => { + screenEntityConfigs[col.columnName] = { + displayColumns: col.entityDisplayConfig!.displayColumns, + separator: col.entityDisplayConfig!.separator || " - ", + sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable, + joinTable: col.entityDisplayConfig!.joinTable, + }; + }); + + console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); + + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + let excludeFilterParam: any = undefined; + if (tableConfig.excludeFilter?.enabled) { + const excludeConfig = tableConfig.excludeFilter; + let filterValue: any = undefined; + + // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) + if (excludeConfig.filterColumn && excludeConfig.filterValueField) { + const fieldName = excludeConfig.filterValueField; + + // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) + if (propFormData && propFormData[fieldName]) { + filterValue = propFormData[fieldName]; + console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + // 2순위: URL 파라미터에서 값 가져오기 + else if (typeof window !== "undefined") { + const urlParams = new URLSearchParams(window.location.search); + filterValue = urlParams.get(fieldName); + if (filterValue) { + console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + } + // 3순위: 분할 패널 부모 데이터에서 값 가져오기 + if (!filterValue && splitPanelContext?.selectedLeftData) { + filterValue = splitPanelContext.selectedLeftData[fieldName]; + if (filterValue) { + console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + } + } + + if (filterValue || !excludeConfig.filterColumn) { + excludeFilterParam = { + enabled: true, + referenceTable: excludeConfig.referenceTable, + referenceColumn: excludeConfig.referenceColumn, + sourceColumn: excludeConfig.sourceColumn, + filterColumn: excludeConfig.filterColumn, + filterValue: filterValue, + }; + console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); + } + } // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { @@ -1200,7 +1262,9 @@ export const TableListComponent: React.FC = ({ search: hasFilters ? filters : undefined, enableEntityJoin: true, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 + excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 }); // 실제 데이터의 item_number만 추출하여 중복 확인 @@ -1756,33 +1820,46 @@ export const TableListComponent: React.FC = ({ const formatCellValue = useCallback( (value: any, column: ColumnConfig, rowData?: Record) => { - if (value === null || value === undefined) return "-"; - - // 🎯 writer 컬럼 자동 변환: user_id -> user_name - if (column.columnName === "writer" && rowData && rowData.writer_name) { - return rowData.writer_name; - } - - // 🎯 엔티티 컬럼 표시 설정이 있는 경우 + // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 + // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) if (column.entityDisplayConfig && rowData) { - // displayColumns 또는 selectedColumns 둘 다 체크 - const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns; + const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; const separator = column.entityDisplayConfig.separator; if (displayColumns && displayColumns.length > 0) { // 선택된 컬럼들의 값을 구분자로 조합 const values = displayColumns - .map((colName) => { - const cellValue = rowData[colName]; + .map((colName: string) => { + // 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우) + let cellValue = rowData[colName]; + + // 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우) + if (cellValue === null || cellValue === undefined) { + const joinedKey = `${column.columnName}_${colName}`; + cellValue = rowData[joinedKey]; + } + if (cellValue === null || cellValue === undefined) return ""; return String(cellValue); }) - .filter((v) => v !== ""); // 빈 값 제외 + .filter((v: string) => v !== ""); // 빈 값 제외 - return values.join(separator || " - "); + const result = values.join(separator || " - "); + if (result) { + return result; // 결과가 있으면 반환 + } + // 결과가 비어있으면 아래로 계속 진행 (원래 값 사용) } } + // value가 null/undefined면 "-" 반환 + if (value === null || value === undefined) return "-"; + + // 🎯 writer 컬럼 자동 변환: user_id -> user_name + if (column.columnName === "writer" && rowData && rowData.writer_name) { + return rowData.writer_name; + } + const meta = columnMeta[column.columnName]; // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) @@ -1906,12 +1983,16 @@ export const TableListComponent: React.FC = ({ return "-"; } - // 숫자 타입 포맷팅 + // 숫자 타입 포맷팅 (천단위 구분자 설정 확인) if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { - return numValue.toLocaleString("ko-KR"); + // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 + if (column.thousandSeparator !== false) { + return numValue.toLocaleString("ko-KR"); + } + return String(numValue); } } return String(value); @@ -1922,7 +2003,11 @@ export const TableListComponent: React.FC = ({ if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { - return numValue.toLocaleString("ko-KR"); + // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 + if (column.thousandSeparator !== false) { + return numValue.toLocaleString("ko-KR"); + } + return String(numValue); } } return String(value); @@ -1939,10 +2024,15 @@ export const TableListComponent: React.FC = ({ } } return "-"; - case "number": - return typeof value === "number" ? value.toLocaleString() : value; case "currency": - return typeof value === "number" ? `₩${value.toLocaleString()}` : value; + if (typeof value === "number") { + // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 + if (column.thousandSeparator !== false) { + return `₩${value.toLocaleString()}`; + } + return `₩${value}`; + } + return value; case "boolean": return value ? "예" : "아니오"; default: diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 9de2f6d8..209b3d2d 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; @@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC = ({ const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + // 🆕 제외 필터용 참조 테이블 컬럼 목록 + const [referenceTableColumns, setReferenceTableColumns] = useState< + Array<{ columnName: string; dataType: string; label?: string }> + >([]); + const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false); + // 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지) useEffect(() => { // console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", { @@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC = ({ fetchEntityJoinColumns(); }, [config.selectedTable, screenTableName]); + // 🆕 제외 필터용 참조 테이블 컬럼 가져오기 + useEffect(() => { + const fetchReferenceColumns = async () => { + const refTable = config.excludeFilter?.referenceTable; + if (!refTable) { + setReferenceTableColumns([]); + return; + } + + setLoadingReferenceColumns(true); + try { + console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable); + const result = await tableManagementApi.getColumnList(refTable); + if (result.success && result.data) { + // result.data는 { columns: [], total, page, size, totalPages } 형태 + const columns = result.data.columns || []; + setReferenceTableColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + dataType: col.dataType || col.data_type || "text", + label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + })) + ); + console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개"); + } + } catch (error) { + console.error("❌ 참조 테이블 컬럼 조회 오류:", error); + setReferenceTableColumns([]); + } finally { + setLoadingReferenceColumns(false); + } + }; + + fetchReferenceColumns(); + }, [config.excludeFilter?.referenceTable]); + // 🎯 엔티티 컬럼 자동 로드 useEffect(() => { const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig); @@ -467,42 +510,22 @@ export const TableListConfigPanel: React.FC = ({ // 🎯 엔티티 컬럼의 표시 컬럼 정보 로드 const loadEntityDisplayConfig = async (column: ColumnConfig) => { - if (!column.isEntityJoin || !column.entityJoinInfo) { - return; - } + const configKey = `${column.columnName}`; + + // 이미 로드된 경우 스킵 + if (entityDisplayConfigs[configKey]) return; - // entityDisplayConfig가 없으면 초기화 - if (!column.entityDisplayConfig) { - // sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서 - const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName; - - if (!initialSourceTable) { - return; - } - - const updatedColumns = config.columns?.map((col) => { - if (col.columnName === column.columnName) { - return { - ...col, - entityDisplayConfig: { - displayColumns: [], - separator: " - ", - sourceTable: initialSourceTable, - joinTable: "", - }, - }; - } - return col; - }); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - // 업데이트된 컬럼으로 다시 시도 - const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName); - if (updatedColumn) { - return loadEntityDisplayConfig(updatedColumn); - } - } + if (!column.isEntityJoin) { + // 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제 + setEntityDisplayConfigs((prev) => ({ + ...prev, + [configKey]: { + sourceColumns: [], + joinColumns: [], + selectedColumns: [], + separator: " - ", + }, + })); return; } @@ -512,32 +535,56 @@ export const TableListConfigPanel: React.FC = ({ // 3. config.selectedTable // 4. screenTableName const sourceTable = - column.entityDisplayConfig.sourceTable || + column.entityDisplayConfig?.sourceTable || column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName; - let joinTable = column.entityDisplayConfig.joinTable; - - // sourceTable이 여전히 비어있으면 에러 + // sourceTable이 비어있으면 빈 상태로 설정 if (!sourceTable) { + console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName); + setEntityDisplayConfigs((prev) => ({ + ...prev, + [configKey]: { + sourceColumns: [], + joinColumns: [], + selectedColumns: column.entityDisplayConfig?.displayColumns || [], + separator: column.entityDisplayConfig?.separator || " - ", + }, + })); return; } - if (!joinTable && sourceTable) { - // joinTable이 없으면 tableTypeApi로 조회해서 설정 + let joinTable = column.entityDisplayConfig?.joinTable; + + // joinTable이 없으면 tableTypeApi로 조회해서 설정 + if (!joinTable) { try { + console.log("🔍 tableTypeApi로 컬럼 정보 조회:", { + tableName: sourceTable, + columnName: column.columnName, + }); + const columnList = await tableTypeApi.getColumns(sourceTable); const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName); + console.log("🔍 컬럼 정보 조회 결과:", { + columnInfo: columnInfo, + referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable, + referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn, + }); + if (columnInfo?.reference_table || columnInfo?.referenceTable) { joinTable = columnInfo.reference_table || columnInfo.referenceTable; + console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable); // entityDisplayConfig 업데이트 const updatedConfig = { ...column.entityDisplayConfig, sourceTable: sourceTable, joinTable: joinTable, + displayColumns: column.entityDisplayConfig?.displayColumns || [], + separator: column.entityDisplayConfig?.separator || " - ", }; // 컬럼 설정 업데이트 @@ -553,74 +600,27 @@ export const TableListConfigPanel: React.FC = ({ } } catch (error) { console.error("tableTypeApi 컬럼 정보 조회 실패:", error); - console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName }); } - } else if (!joinTable) { - console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName); } console.log("🔍 최종 추출한 값:", { sourceTable, joinTable }); - const configKey = `${column.columnName}`; - - // 이미 로드된 경우 스킵 - if (entityDisplayConfigs[configKey]) return; - - // joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기 - let actualJoinTable = joinTable; - if (!actualJoinTable && sourceTable) { - try { - console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", { - tableName: sourceTable, - columnName: column.columnName, - }); - - const columnList = await tableTypeApi.getColumns(sourceTable); - const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName); - - console.log("🔍 컬럼 정보 조회 결과:", { - columnInfo: columnInfo, - referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable, - referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn, - }); - - if (columnInfo?.reference_table || columnInfo?.referenceTable) { - actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable; - console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable); - - // entityDisplayConfig 업데이트 - const updatedConfig = { - ...column.entityDisplayConfig, - joinTable: actualJoinTable, - }; - - // 컬럼 설정 업데이트 - const updatedColumns = config.columns?.map((col) => - col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col, - ); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - } - } - } catch (error) { - console.error("tableTypeApi 컬럼 정보 조회 실패:", error); - } - } - - // sourceTable과 joinTable이 모두 있어야 로드 - if (!sourceTable || !actualJoinTable) { - return; - } try { - // 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드 - const [sourceResult, joinResult] = await Promise.all([ - entityJoinApi.getReferenceTableColumns(sourceTable), - entityJoinApi.getReferenceTableColumns(actualJoinTable), - ]); - + // 기본 테이블 컬럼 정보는 항상 로드 + const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable); const sourceColumns = sourceResult.columns || []; - const joinColumns = joinResult.columns || []; + + // joinTable이 있으면 조인 테이블 컬럼도 로드 + let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = []; + if (joinTable) { + try { + const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable); + joinColumns = joinResult.columns || []; + } catch (joinError) { + console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError); + // 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시 + } + } setEntityDisplayConfigs((prev) => ({ ...prev, @@ -633,6 +633,16 @@ export const TableListConfigPanel: React.FC = ({ })); } catch (error) { console.error("엔티티 표시 컬럼 정보 로드 실패:", error); + // 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제 + setEntityDisplayConfigs((prev) => ({ + ...prev, + [configKey]: { + sourceColumns: [], + joinColumns: [], + selectedColumns: column.entityDisplayConfig?.displayColumns || [], + separator: column.entityDisplayConfig?.separator || " - ", + }, + })); } }; @@ -873,76 +883,95 @@ export const TableListConfigPanel: React.FC = ({ {/* 표시 컬럼 선택 (다중 선택) */}
- - - - - - - - - 컬럼을 찾을 수 없습니다. - {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( - - {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => ( - toggleEntityDisplayColumn(column.columnName, col.columnName)} - className="text-xs" - > - - {col.displayName} - - ))} - - )} - {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && ( - - {entityDisplayConfigs[column.columnName].joinColumns.map((col) => ( - toggleEntityDisplayColumn(column.columnName, col.columnName)} - className="text-xs" - > - - {col.displayName} - - ))} - - )} - - - - + {entityDisplayConfigs[column.columnName].sourceColumns.length === 0 && + entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? ( +
+ 표시 가능한 컬럼이 없습니다. + {!column.entityDisplayConfig?.joinTable && ( +

+ 테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다. +

+ )} +
+ ) : ( + + + + + + + + + 컬럼을 찾을 수 없습니다. + {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( + + {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => ( + toggleEntityDisplayColumn(column.columnName, col.columnName)} + className="text-xs" + > + + {col.displayName} + + ))} + + )} + {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && ( + + {entityDisplayConfigs[column.columnName].joinColumns.map((col) => ( + toggleEntityDisplayColumn(column.columnName, col.columnName)} + className="text-xs" + > + + {col.displayName} + + ))} + + )} + + + + + )}
+ {/* 참조 테이블 미설정 안내 */} + {!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( +
+ 현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 테이블의 컬럼도 선택할 수 있습니다. +
+ )} + {/* 선택된 컬럼 미리보기 */} {entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
@@ -1074,86 +1103,111 @@ export const TableListConfigPanel: React.FC = ({ {/* 간결한 리스트 형식 컬럼 설정 */}
- {config.columns?.map((column, index) => ( -
- {/* 컬럼명 */} - - {availableColumns.find((col) => col.columnName === column.columnName)?.label || - column.displayName || - column.columnName} - + {config.columns?.map((column, index) => { + // 해당 컬럼의 input_type 확인 + const columnInfo = availableColumns.find((col) => col.columnName === column.columnName); + const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal"; + + return ( +
+
+ {/* 컬럼명 */} + + {columnInfo?.label || column.displayName || column.columnName} + + + {/* 숫자 타입인 경우 천단위 구분자 설정 */} + {isNumberType && ( +
+ { + updateColumn(column.columnName, { thousandSeparator: checked as boolean }); + }} + className="h-3 w-3" + /> + +
+ )} +
- {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */} -
- f.columnName === column.columnName) || false} - onCheckedChange={(checked) => { - const currentFilters = config.filter?.filters || []; - const columnLabel = - availableColumns.find((col) => col.columnName === column.columnName)?.label || - column.displayName || - column.columnName; + {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */} +
+ f.columnName === column.columnName) || false} + onCheckedChange={(checked) => { + const currentFilters = config.filter?.filters || []; + const columnLabel = + columnInfo?.label || column.displayName || column.columnName; - if (checked) { - // 필터 추가 - handleChange("filter", { - ...config.filter, - enabled: true, - filters: [ - ...currentFilters, - { - columnName: column.columnName, - label: columnLabel, - type: "text", - }, - ], - }); - } else { - // 필터 제거 - handleChange("filter", { - ...config.filter, - filters: currentFilters.filter((f) => f.columnName !== column.columnName), - }); - } - }} - className="h-3 w-3" - /> + if (checked) { + // 필터 추가 + handleChange("filter", { + ...config.filter, + enabled: true, + filters: [ + ...currentFilters, + { + columnName: column.columnName, + label: columnLabel, + type: "text", + }, + ], + }); + } else { + // 필터 제거 + handleChange("filter", { + ...config.filter, + filters: currentFilters.filter((f) => f.columnName !== column.columnName), + }); + } + }} + className="h-3 w-3" + /> +
+ + {/* 순서 변경 + 삭제 버튼 */} +
+ + + +
- - {/* 순서 변경 + 삭제 버튼 */} -
- - - -
-
- ))} + ); + })}
)} @@ -1322,6 +1376,298 @@ export const TableListConfigPanel: React.FC = ({

+ + {/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */} +
+
+

제외 필터

+

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

+
+
+ + {/* 제외 필터 활성화 */} +
+ { + handleChange("excludeFilter", { + ...config.excludeFilter, + enabled: checked as boolean, + }); + }} + /> + +
+ + {config.excludeFilter?.enabled && ( +
+ {/* 참조 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + referenceTable: table.tableName, + referenceColumn: undefined, + sourceColumn: undefined, + filterColumn: undefined, + filterValueField: undefined, + }); + }} + className="text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + + + +
+ + {config.excludeFilter?.referenceTable && ( + <> + {/* 비교 컬럼 설정 - 한 줄에 두 개 */} +
+ {/* 참조 컬럼 (매핑 테이블) */} +
+ + + + + + + + + + 없음 + + {referenceTableColumns.map((col) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + referenceColumn: col.columnName, + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+ + {/* 소스 컬럼 (현재 테이블) */} +
+ + + + + + + + + + 없음 + + {availableColumns.map((col) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + sourceColumn: col.columnName, + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+
+ + {/* 조건 필터 - 특정 조건의 데이터만 제외 */} +
+ +

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

+
+ {/* 필터 컬럼 (매핑 테이블) */} + + + + + + + + + 없음 + + { + handleChange("excludeFilter", { + ...config.excludeFilter, + filterColumn: undefined, + filterValueField: undefined, + }); + }} + className="text-xs text-muted-foreground" + > + + 사용 안함 + + {referenceTableColumns.map((col) => ( + { + // 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정 + handleChange("excludeFilter", { + ...config.excludeFilter, + filterColumn: col.columnName, + filterValueField: col.columnName, // 같은 이름으로 자동 설정 + filterValueSource: "url", + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + + + {/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */} + { + handleChange("excludeFilter", { + ...config.excludeFilter, + filterValueField: e.target.value, + }); + }} + disabled={!config.excludeFilter?.filterColumn} + className="h-8 text-xs" + /> +
+
+ + )} + + {/* 설정 요약 */} + {config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && ( +
+ 설정 요약: {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가 + {" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에 + {config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && ( + <> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때) + )} + {" "}이미 있으면 제외 +
+ )} +
+ )} +
); diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 0322926b..2475f58f 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -59,6 +59,9 @@ export interface ColumnConfig { isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부 entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보 + // 숫자 포맷팅 설정 + thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true) + // 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정) entityDisplayConfig?: { displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블) @@ -182,6 +185,21 @@ export interface LinkedFilterConfig { enabled?: boolean; // 활성화 여부 (기본: true) } +/** + * 제외 필터 설정 + * 다른 테이블에 이미 존재하는 데이터를 제외하고 표시 + * 예: 거래처에 이미 등록된 품목을 품목 선택 모달에서 제외 + */ +export interface ExcludeFilterConfig { + enabled: boolean; // 제외 필터 활성화 여부 + referenceTable: string; // 참조 테이블 (예: customer_item_mapping) + referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id) + sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number) + filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id) + filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url) + filterValueField?: string; // 필터 값 필드명 (예: customer_code) +} + /** * TableList 컴포넌트 설정 타입 */ @@ -246,6 +264,9 @@ export interface TableListConfig extends ComponentConfig { // 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링) linkedFilters?: LinkedFilterConfig[]; + // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + excludeFilter?: ExcludeFilterConfig; + // 이벤트 핸들러 onRowClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx new file mode 100644 index 00000000..85133424 --- /dev/null +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -0,0 +1,1086 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { generateNumberingCode } from "@/lib/api/numberingRule"; + +import { + UniversalFormModalComponentProps, + UniversalFormModalConfig, + FormSectionConfig, + FormFieldConfig, + FormDataState, + RepeatSectionItem, + SelectOptionConfig, +} from "./types"; +import { defaultConfig, generateUniqueId } from "./config"; + +/** + * 범용 폼 모달 컴포넌트 + * + * 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원합니다. + */ +export function UniversalFormModalComponent({ + component, + config: propConfig, + isDesignMode = false, + isSelected = false, + className, + style, + initialData, + onSave, + onCancel, + onChange, +}: UniversalFormModalComponentProps) { + // 설정 병합 + const config: UniversalFormModalConfig = useMemo(() => { + const componentConfig = component?.config || {}; + return { + ...defaultConfig, + ...propConfig, + ...componentConfig, + modal: { + ...defaultConfig.modal, + ...propConfig?.modal, + ...componentConfig.modal, + }, + saveConfig: { + ...defaultConfig.saveConfig, + ...propConfig?.saveConfig, + ...componentConfig.saveConfig, + multiRowSave: { + ...defaultConfig.saveConfig.multiRowSave, + ...propConfig?.saveConfig?.multiRowSave, + ...componentConfig.saveConfig?.multiRowSave, + }, + afterSave: { + ...defaultConfig.saveConfig.afterSave, + ...propConfig?.saveConfig?.afterSave, + ...componentConfig.saveConfig?.afterSave, + }, + }, + }; + }, [component?.config, propConfig]); + + // 폼 데이터 상태 + const [formData, setFormData] = useState({}); + const [, setOriginalData] = useState>({}); + + // 반복 섹션 데이터 + const [repeatSections, setRepeatSections] = useState<{ + [sectionId: string]: RepeatSectionItem[]; + }>({}); + + // 섹션 접힘 상태 + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + + // Select 옵션 캐시 + const [selectOptionsCache, setSelectOptionsCache] = useState<{ + [key: string]: { value: string; label: string }[]; + }>({}); + + // 로딩 상태 + const [saving, setSaving] = useState(false); + + // 삭제 확인 다이얼로그 + const [deleteDialog, setDeleteDialog] = useState<{ + open: boolean; + sectionId: string; + itemId: string; + }>({ open: false, sectionId: "", itemId: "" }); + + // 초기화 + useEffect(() => { + initializeForm(); + }, [config, initialData]); + + // 폼 초기화 + const initializeForm = useCallback(async () => { + const newFormData: FormDataState = {}; + const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; + const newCollapsed = new Set(); + + // 섹션별 초기화 + for (const section of config.sections) { + // 접힘 상태 초기화 + if (section.defaultCollapsed) { + newCollapsed.add(section.id); + } + + if (section.repeatable) { + // 반복 섹션 초기화 + const minItems = section.repeatConfig?.minItems || 0; + const items: RepeatSectionItem[] = []; + for (let i = 0; i < minItems; i++) { + items.push(createRepeatItem(section, i)); + } + newRepeatSections[section.id] = items; + } else { + // 일반 섹션 필드 초기화 + for (const field of section.fields) { + // 기본값 설정 + let value = field.defaultValue ?? ""; + + // 부모에서 전달받은 값 적용 + if (field.receiveFromParent && initialData) { + const parentField = field.parentFieldName || field.columnName; + if (initialData[parentField] !== undefined) { + value = initialData[parentField]; + } + } + + newFormData[field.columnName] = value; + } + } + } + + setFormData(newFormData); + setRepeatSections(newRepeatSections); + setCollapsedSections(newCollapsed); + setOriginalData(initialData || {}); + + // 채번규칙 자동 생성 + await generateNumberingValues(newFormData); + }, [config, initialData]); + + // 반복 섹션 아이템 생성 + const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { + const item: RepeatSectionItem = { + _id: generateUniqueId("repeat"), + _index: index, + }; + + for (const field of section.fields) { + item[field.columnName] = field.defaultValue ?? ""; + } + + return item; + }; + + // 채번규칙 자동 생성 + const generateNumberingValues = useCallback( + async (currentFormData: FormDataState) => { + const updatedData = { ...currentFormData }; + let hasChanges = false; + + for (const section of config.sections) { + if (section.repeatable) continue; + + for (const field of section.fields) { + if ( + field.numberingRule?.enabled && + field.numberingRule?.generateOnOpen && + field.numberingRule?.ruleId && + !updatedData[field.columnName] + ) { + try { + const response = await generateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + updatedData[field.columnName] = response.data.generatedCode; + hasChanges = true; + } + } catch (error) { + console.error(`채번규칙 생성 실패 (${field.columnName}):`, error); + } + } + } + } + + if (hasChanges) { + setFormData(updatedData); + } + }, + [config], + ); + + // 필드 값 변경 핸들러 + const handleFieldChange = useCallback( + (columnName: string, value: any) => { + setFormData((prev) => { + const newData = { ...prev, [columnName]: value }; + // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) + if (onChange) { + setTimeout(() => onChange(newData), 0); + } + return newData; + }); + }, + [onChange], + ); + + // 반복 섹션 필드 값 변경 핸들러 + const handleRepeatFieldChange = useCallback((sectionId: string, itemId: string, columnName: string, value: any) => { + setRepeatSections((prev) => { + const items = prev[sectionId] || []; + const newItems = items.map((item) => (item._id === itemId ? { ...item, [columnName]: value } : item)); + return { ...prev, [sectionId]: newItems }; + }); + }, []); + + // 반복 섹션 아이템 추가 + const handleAddRepeatItem = useCallback( + (sectionId: string) => { + const section = config.sections.find((s) => s.id === sectionId); + if (!section) return; + + const maxItems = section.repeatConfig?.maxItems || 10; + + setRepeatSections((prev) => { + const items = prev[sectionId] || []; + if (items.length >= maxItems) { + toast.error(`최대 ${maxItems}개까지만 추가할 수 있습니다.`); + return prev; + } + + const newItem = createRepeatItem(section, items.length); + return { ...prev, [sectionId]: [...items, newItem] }; + }); + }, + [config], + ); + + // 반복 섹션 아이템 삭제 + const handleRemoveRepeatItem = useCallback( + (sectionId: string, itemId: string) => { + const section = config.sections.find((s) => s.id === sectionId); + if (!section) return; + + const minItems = section.repeatConfig?.minItems || 0; + + setRepeatSections((prev) => { + const items = prev[sectionId] || []; + if (items.length <= minItems) { + toast.error(`최소 ${minItems}개는 유지해야 합니다.`); + return prev; + } + + const newItems = items.filter((item) => item._id !== itemId).map((item, index) => ({ ...item, _index: index })); + + return { ...prev, [sectionId]: newItems }; + }); + + setDeleteDialog({ open: false, sectionId: "", itemId: "" }); + }, + [config], + ); + + // 섹션 접힘 토글 + const toggleSectionCollapse = useCallback((sectionId: string) => { + setCollapsedSections((prev) => { + const newSet = new Set(prev); + if (newSet.has(sectionId)) { + newSet.delete(sectionId); + } else { + newSet.add(sectionId); + } + return newSet; + }); + }, []); + + // Select 옵션 로드 + const loadSelectOptions = useCallback( + async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => { + // 캐시 확인 + if (selectOptionsCache[fieldId]) { + return selectOptionsCache[fieldId]; + } + + let options: { value: string; label: string }[] = []; + + try { + if (optionConfig.type === "static") { + options = optionConfig.staticOptions || []; + } else if (optionConfig.type === "table" && optionConfig.tableName) { + const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, { + params: { limit: 1000 }, + }); + if (response.data?.success && response.data?.data) { + options = response.data.data.map((row: any) => ({ + value: String(row[optionConfig.valueColumn || "id"]), + label: String(row[optionConfig.labelColumn || "name"]), + })); + } + } else if (optionConfig.type === "code" && optionConfig.codeCategory) { + const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`); + if (response.data?.success && response.data?.data) { + options = response.data.data.map((code: any) => ({ + value: code.code_value || code.codeValue, + label: code.code_name || code.codeName, + })); + } + } + + // 캐시 저장 + setSelectOptionsCache((prev) => ({ ...prev, [fieldId]: options })); + } catch (error) { + console.error(`Select 옵션 로드 실패 (${fieldId}):`, error); + } + + return options; + }, + [selectOptionsCache], + ); + + // 필수 필드 검증 + const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { + const missingFields: string[] = []; + + for (const section of config.sections) { + if (section.repeatable) continue; // 반복 섹션은 별도 검증 + + for (const field of section.fields) { + if (field.required && !field.hidden && !field.numberingRule?.hidden) { + const value = formData[field.columnName]; + if (value === undefined || value === null || value === "") { + missingFields.push(field.label || field.columnName); + } + } + } + } + + return { valid: missingFields.length === 0, missingFields }; + }, [config.sections, formData]); + + // 저장 처리 + const handleSave = useCallback(async () => { + if (!config.saveConfig.tableName) { + toast.error("저장할 테이블이 설정되지 않았습니다."); + return; + } + + // 필수 필드 검증 + const { valid, missingFields } = validateRequiredFields(); + if (!valid) { + toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); + return; + } + + setSaving(true); + + try { + const { multiRowSave } = config.saveConfig; + + if (multiRowSave?.enabled) { + // 다중 행 저장 + await saveMultipleRows(); + } else { + // 단일 행 저장 + await saveSingleRow(); + } + + // 저장 후 동작 + if (config.saveConfig.afterSave?.showToast) { + toast.success("저장되었습니다."); + } + + if (config.saveConfig.afterSave?.refreshParent) { + window.dispatchEvent(new CustomEvent("refreshParentData")); + } + + // onSave 콜백은 저장 완료 알림용으로만 사용 + // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) + // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 + // _saveCompleted 플래그를 포함하여 전달 + if (onSave) { + onSave({ ...formData, _saveCompleted: true }); + } + } catch (error: any) { + console.error("저장 실패:", error); + toast.error(error.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }, [config, formData, repeatSections, onSave, validateRequiredFields]); + + // 단일 행 저장 + const saveSingleRow = async () => { + const dataToSave = { ...formData }; + + // 메타데이터 필드 제거 + Object.keys(dataToSave).forEach((key) => { + if (key.startsWith("_")) { + delete dataToSave[key]; + } + }); + + // 저장 시점 채번규칙 처리 + for (const section of config.sections) { + for (const field of section.fields) { + if ( + field.numberingRule?.enabled && + field.numberingRule?.generateOnSave && + field.numberingRule?.ruleId && + !dataToSave[field.columnName] + ) { + const response = await generateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + dataToSave[field.columnName] = response.data.generatedCode; + } + } + } + } + + const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave); + + if (!response.data?.success) { + throw new Error(response.data?.message || "저장 실패"); + } + }; + + // 다중 행 저장 (겸직 등) + const saveMultipleRows = async () => { + const { multiRowSave } = config.saveConfig; + if (!multiRowSave) return; + + let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = + multiRowSave; + + // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 + if (commonFields.length === 0) { + const nonRepeatableSections = config.sections.filter((s) => !s.repeatable); + commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName)); + console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields); + } + + // 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용 + if (!repeatSectionId) { + const repeatableSection = config.sections.find((s) => s.repeatable); + if (repeatableSection) { + repeatSectionId = repeatableSection.id; + console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId); + } + } + + // 디버깅: 설정 확인 + console.log("[UniversalFormModal] 다중 행 저장 설정:", { + commonFields, + mainSectionFields, + repeatSectionId, + typeColumn, + mainTypeValue, + subTypeValue, + }); + console.log("[UniversalFormModal] 현재 formData:", formData); + + // 공통 필드 데이터 추출 + const commonData: Record = {}; + for (const fieldName of commonFields) { + if (formData[fieldName] !== undefined) { + commonData[fieldName] = formData[fieldName]; + } + } + console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData); + + // 메인 섹션 필드 데이터 추출 + const mainSectionData: Record = {}; + if (mainSectionFields && mainSectionFields.length > 0) { + for (const fieldName of mainSectionFields) { + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; + } + } + } + console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData); + + // 저장할 행들 준비 + const rowsToSave: Record[] = []; + + // 1. 메인 행 생성 + const mainRow: Record = { + ...commonData, + ...mainSectionData, + }; + if (typeColumn) { + mainRow[typeColumn] = mainTypeValue || "main"; + } + rowsToSave.push(mainRow); + + // 2. 반복 섹션 행들 생성 (겸직 등) + const repeatItems = repeatSections[repeatSectionId] || []; + for (const item of repeatItems) { + const subRow: Record = { ...commonData }; + + // 반복 섹션 필드 복사 + Object.keys(item).forEach((key) => { + if (!key.startsWith("_")) { + subRow[key] = item[key]; + } + }); + + if (typeColumn) { + subRow[typeColumn] = subTypeValue || "concurrent"; + } + + rowsToSave.push(subRow); + } + + // 저장 시점 채번규칙 처리 (메인 행만) + for (const section of config.sections) { + if (section.repeatable) continue; + + for (const field of section.fields) { + if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { + const response = await generateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + // 모든 행에 동일한 채번 값 적용 (공통 필드인 경우) + if (commonFields.includes(field.columnName)) { + rowsToSave.forEach((row) => { + row[field.columnName] = response.data?.generatedCode; + }); + } else { + rowsToSave[0][field.columnName] = response.data?.generatedCode; + } + } + } + } + } + + // 모든 행 저장 + console.log("[UniversalFormModal] 저장할 행들:", rowsToSave); + console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName); + + for (let i = 0; i < rowsToSave.length; i++) { + const row = rowsToSave[i]; + console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row); + + // 빈 객체 체크 + if (Object.keys(row).length === 0) { + console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`); + continue; + } + + const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row); + + if (!response.data?.success) { + throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`); + } + } + + console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); + }; + + // 폼 초기화 + const handleReset = useCallback(() => { + initializeForm(); + toast.info("폼이 초기화되었습니다."); + }, [initializeForm]); + + // 필드 요소 렌더링 (입력 컴포넌트만) + const renderFieldElement = ( + field: FormFieldConfig, + value: any, + onChangeHandler: (value: any) => void, + fieldKey: string, + isDisabled: boolean, + ) => { + return (() => { + switch (field.fieldType) { + case "textarea": + return ( +