"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers, ChevronUp, ChevronDown } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { RepeatScreenModalProps, CardRowConfig, CardColumnConfig, ColumnSourceConfig, ColumnTargetConfig, GroupingConfig, AggregationConfig, TableLayoutConfig, TableColumnConfig, CardContentRowConfig, AggregationDisplayConfig, SyncSaveConfig, RowNumberingConfig, } from "./types"; 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; onChange: (config: Partial) => void; } // 검색 가능한 컬럼 선택기 (Combobox) - 240px 최적화 function SourceColumnSelector({ sourceTable, value, onChange, placeholder = "컬럼 선택", }: { sourceTable: string; value: string; onChange: (value: string) => void; placeholder?: string; showTableName?: boolean; }) { const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { const loadColumns = async () => { if (!sourceTable) { setColumns([]); return; } setIsLoading(true); try { const response = await tableManagementApi.getColumnList(sourceTable); if (response.success && response.data) { setColumns(response.data.columns); } } catch (error) { console.error("컬럼 로드 실패:", error); setColumns([]); } finally { setIsLoading(false); } }; loadColumns(); }, [sourceTable]); const selectedColumn = columns.find((col) => col.columnName === value); const displayText = selectedColumn ? selectedColumn.columnName : placeholder; return ( 없음 {columns.map((col) => ( { onChange(col.columnName); setOpen(false); }} className="text-[10px] py-1" > {col.columnName} ))} ); } // 카드 제목 편집기 - 직접 입력 + 필드 삽입 방식 function CardTitleEditor({ sourceTable, currentValue, onChange, }: { sourceTable: string; currentValue: string; onChange: (value: string) => void; }) { const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); const [localValue, setLocalValue] = useState(currentValue || ""); const inputRef = React.useRef(null); useEffect(() => { setLocalValue(currentValue || ""); }, [currentValue]); useEffect(() => { const loadColumns = async () => { if (!sourceTable) { setColumns([]); return; } setIsLoading(true); try { const response = await tableManagementApi.getColumnList(sourceTable); if (response.success && response.data) { setColumns(response.data.columns || []); } } catch (error) { console.error("컬럼 로드 실패:", error); setColumns([]); } finally { setIsLoading(false); } }; loadColumns(); }, [sourceTable]); // 필드 삽입 (현재 커서 위치 또는 끝에) const insertField = (fieldName: string) => { const newValue = localValue ? `${localValue} - {${fieldName}}` : `{${fieldName}}`; setLocalValue(newValue); onChange(newValue); setOpen(false); }; // 추천 템플릿 const templateOptions = useMemo(() => { const options = [ { value: "카드 {index}", label: "카드 {index} - 순번만" }, ]; if (columns.length > 0) { // part_code - part_name 패턴 찾기 const codeCol = columns.find((c) => c.columnName.toLowerCase().includes("code") || c.columnName.toLowerCase().includes("no") ); const nameCol = columns.find((c) => c.columnName.toLowerCase().includes("name") && !c.columnName.toLowerCase().includes("code") ); if (codeCol && nameCol) { options.push({ value: `{${codeCol.columnName}} - {${nameCol.columnName}}`, label: `{${codeCol.columnName}} - {${nameCol.columnName}} (추천)`, }); } // 첫 번째 컬럼 단일 const firstCol = columns[0]; options.push({ value: `{${firstCol.columnName}}`, label: `{${firstCol.columnName}}${firstCol.displayName ? ` - ${firstCol.displayName}` : ""}`, }); } return options; }, [columns]); // 입력값 변경 핸들러 const handleInputChange = (e: React.ChangeEvent) => { setLocalValue(e.target.value); }; // 입력 완료 (blur 또는 Enter) const handleInputBlur = () => { if (localValue !== currentValue) { onChange(localValue); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { handleInputBlur(); } }; return (
{/* 직접 입력 필드 */}
없음 {/* 추천 템플릿 */} {templateOptions.map((opt) => ( { setLocalValue(opt.value); onChange(opt.value); setOpen(false); }} className="text-[10px] py-1" > {opt.label} ))} {/* 필드 삽입 */} {columns.length > 0 && ( insertField("index")} className="text-[10px] py-1" > index - 순번 {columns.map((col) => ( insertField(col.columnName)} className="text-[10px] py-1" > {col.columnName} {col.displayName && ( - {col.displayName} )} ))} )}
{/* 안내 텍스트 */}

직접 입력하거나 + 버튼으로 필드 추가. 예: {"{part_code} - {part_name}"}

); } // 🆕 v3.2: 시각적 수식 빌더 interface FormulaToken { id: string; type: "aggregation" | "column" | "operator" | "number"; // aggregation: 이전 집계 결과 참조 aggregationField?: string; // column: 테이블 컬럼 집계 table?: string; column?: string; aggFunction?: "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "SUM_EXT" | "COUNT_EXT" | "AVG_EXT" | "MIN_EXT" | "MAX_EXT" | "VALUE"; isExternal?: boolean; // operator: 연산자 operator?: "+" | "-" | "*" | "/" | "(" | ")"; // number: 숫자 value?: number; } function FormulaBuilder({ formula, sourceTable, allTables, referenceableAggregations, onChange, }: { formula: string; sourceTable: string; allTables: { tableName: string; displayName?: string }[]; referenceableAggregations: AggregationConfig[]; onChange: (formula: string) => void; }) { // 수식 토큰 상태 const [tokens, setTokens] = useState([]); // 새 토큰 추가용 상태 const [newTokenType, setNewTokenType] = useState<"aggregation" | "column">("aggregation"); const [newTokenTable, setNewTokenTable] = useState(sourceTable || ""); const [newTokenColumn, setNewTokenColumn] = useState(""); const [newTokenAggFunction, setNewTokenAggFunction] = useState("SUM"); const [newTokenAggField, setNewTokenAggField] = useState(""); // formula 문자열에서 토큰 파싱 (초기화용) useEffect(() => { if (!formula) { setTokens([]); return; } // 간단한 파싱: 기존 formula가 있으면 토큰으로 변환 시도 const parsed = parseFormulaToTokens(formula, sourceTable); if (parsed.length > 0) { setTokens(parsed); } }, []); // 토큰을 formula 문자열로 변환 const tokensToFormula = (tokenList: FormulaToken[]): string => { return tokenList.map((token) => { switch (token.type) { case "aggregation": return `{${token.aggregationField}}`; case "column": if (token.aggFunction === "VALUE") { return `{${token.column}}`; } return `${token.aggFunction}({${token.column}})`; case "operator": return ` ${token.operator} `; case "number": return String(token.value); default: return ""; } }).join(""); }; // formula 문자열에서 토큰 파싱 (간단한 버전) const parseFormulaToTokens = (formulaStr: string, defaultTable: string): FormulaToken[] => { const result: FormulaToken[] = []; // 간단한 파싱 - 복잡한 경우는 수동 입력 모드로 전환 // 이 함수는 기존 formula가 있을 때 최대한 파싱 시도 const parts = formulaStr.split(/(\s*[+\-*/()]\s*)/); for (const part of parts) { const trimmed = part.trim(); if (!trimmed) continue; // 연산자 if (["+", "-", "*", "/", "(", ")"].includes(trimmed)) { result.push({ id: `op-${Date.now()}-${Math.random()}`, type: "operator", operator: trimmed as FormulaToken["operator"], }); continue; } // 집계 함수: SUM({column}), SUM_EXT({column}) const aggMatch = trimmed.match(/^(SUM|COUNT|AVG|MIN|MAX)(_EXT)?\(\{(\w+)\}\)$/); if (aggMatch) { result.push({ id: `col-${Date.now()}-${Math.random()}`, type: "column", table: aggMatch[2] ? "" : defaultTable, // _EXT면 외부 테이블 column: aggMatch[3], aggFunction: (aggMatch[1] + (aggMatch[2] || "")) as FormulaToken["aggFunction"], isExternal: !!aggMatch[2], }); continue; } // 필드 참조: {fieldName} const fieldMatch = trimmed.match(/^\{(\w+)\}$/); if (fieldMatch) { result.push({ id: `agg-${Date.now()}-${Math.random()}`, type: "aggregation", aggregationField: fieldMatch[1], }); continue; } // 숫자 const num = parseFloat(trimmed); if (!isNaN(num)) { result.push({ id: `num-${Date.now()}-${Math.random()}`, type: "number", value: num, }); } } return result; }; // 토큰 추가 const addToken = (token: FormulaToken) => { const newTokens = [...tokens, token]; setTokens(newTokens); onChange(tokensToFormula(newTokens)); }; // 토큰 삭제 const removeToken = (tokenId: string) => { const newTokens = tokens.filter((t) => t.id !== tokenId); setTokens(newTokens); onChange(tokensToFormula(newTokens)); }; // 연산자 추가 const addOperator = (op: FormulaToken["operator"]) => { addToken({ id: `op-${Date.now()}`, type: "operator", operator: op, }); }; // 집계 참조 추가 const addAggregationRef = () => { if (!newTokenAggField) return; addToken({ id: `agg-${Date.now()}`, type: "aggregation", aggregationField: newTokenAggField, }); setNewTokenAggField(""); }; // 컬럼 집계 추가 const addColumnAgg = () => { if (!newTokenColumn) return; const isExternal = newTokenTable !== sourceTable; let aggFunc = newTokenAggFunction; // 외부 테이블이면 _EXT 붙이기 if (isExternal && aggFunc && !aggFunc.endsWith("_EXT") && aggFunc !== "VALUE") { aggFunc = (aggFunc + "_EXT") as FormulaToken["aggFunction"]; } addToken({ id: `col-${Date.now()}`, type: "column", table: newTokenTable, column: newTokenColumn, aggFunction: aggFunc, isExternal, }); setNewTokenColumn(""); }; // 토큰 표시 텍스트 const getTokenDisplay = (token: FormulaToken): string => { switch (token.type) { case "aggregation": const refAgg = referenceableAggregations.find((a) => a.resultField === token.aggregationField); return refAgg?.label || token.aggregationField || ""; case "column": if (token.aggFunction === "VALUE") { return `${token.column}`; } return `${token.aggFunction}(${token.column})`; case "operator": return token.operator || ""; case "number": return String(token.value); default: return ""; } }; // 토큰 배지 색상 const getTokenBadgeClass = (token: FormulaToken): string => { switch (token.type) { case "aggregation": return "bg-blue-100 text-blue-700 border-blue-200"; case "column": return token.isExternal ? "bg-orange-100 text-orange-700 border-orange-200" : "bg-green-100 text-green-700 border-green-200"; case "operator": return "bg-gray-100 text-gray-700 border-gray-200"; case "number": return "bg-purple-100 text-purple-700 border-purple-200"; default: return ""; } }; return (
{/* 현재 수식 표시 */}
{tokens.length === 0 ? ( 아래에서 요소를 추가하세요 ) : ( tokens.map((token) => ( removeToken(token.id)} title="클릭하여 삭제" > {getTokenDisplay(token)} )) )}
{/* 생성된 수식 미리보기 */} {tokens.length > 0 && (

{tokensToFormula(tokens)}

)}
{/* 연산자 버튼 */}
{["+", "-", "*", "/", "(", ")"].map((op) => ( ))}
{/* 집계 참조 추가 */} {referenceableAggregations.length > 0 && (
참조할 집계 선택
)} {/* 테이블 컬럼 집계 추가 */}
{/* 테이블 선택 */}
테이블
{/* 컬럼 선택 */}
컬럼
{/* 집계 함수 및 추가 버튼 */}
집계 함수
{newTokenTable !== sourceTable && newTokenTable && (

외부 테이블: _EXT 함수 사용

)}
{/* 수동 입력 모드 토글 */}
수동 입력 모드
{ const parsed = parseFormulaToTokens(e.target.value, sourceTable); setTokens(parsed); onChange(e.target.value); }} placeholder="{total_balance} - SUM_EXT({plan_qty})" className="h-6 text-[10px] font-mono" />

직접 수식 입력. 예: {"{"}resultField{"}"}, SUM({"{"}column{"}"}), SUM_EXT({"{"}column{"}"})

); } // 🆕 집계 설정 전용 모달 function AggregationSettingsModal({ open, onOpenChange, aggregations, sourceTable, allTables, contentRows, onSave, }: { open: boolean; onOpenChange: (open: boolean) => void; aggregations: AggregationConfig[]; sourceTable: string; allTables: { tableName: string; displayName?: string }[]; contentRows: CardContentRowConfig[]; 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, contentRows, onUpdate, onRemove, onMove, }: { agg: AggregationConfig; index: number; totalCount: number; sourceTable: string; allTables: { tableName: string; displayName?: string }[]; existingAggregations: AggregationConfig[]; contentRows: CardContentRowConfig[]; 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 }); }} />
{/* 🆕 v3.11: SUM_EXT 참조 테이블 선택 */} {localFormula.includes("_EXT") && ( onUpdate({ externalTableRefs: refs })} /> )}
)} {/* 라벨 및 숨김 설정 */}
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" && (

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

)}
); } // 🆕 v3.11: SUM_EXT 참조 테이블 선택 컴포넌트 function ExternalTableRefSelector({ contentRows, selectedRefs, onUpdate, }: { contentRows: CardContentRowConfig[]; selectedRefs: string[]; onUpdate: (refs: string[]) => void; }) { // 외부 데이터 소스가 활성화된 테이블 행만 필터링 const tableRowsWithExternalSource = contentRows.filter( (row) => row.type === "table" && row.tableDataSource?.enabled ); if (tableRowsWithExternalSource.length === 0) { return (

레이아웃에 외부 데이터 소스가 설정된 테이블 행이 없습니다.

); } const isAllSelected = selectedRefs.length === 0; const handleToggleTable = (tableId: string) => { if (selectedRefs.includes(tableId)) { // 이미 선택된 경우 제거 const newRefs = selectedRefs.filter((id) => id !== tableId); onUpdate(newRefs); } else { // 선택되지 않은 경우 추가 onUpdate([...selectedRefs, tableId]); } }; const handleSelectAll = () => { onUpdate([]); // 빈 배열 = 모든 테이블 사용 }; return (

SUM_EXT 함수가 참조할 테이블을 선택하세요. 선택하지 않으면 모든 외부 테이블 데이터를 사용합니다.

{tableRowsWithExternalSource.map((row) => { const isSelected = selectedRefs.length === 0 || selectedRefs.includes(row.id); const tableTitle = row.title || row.tableDataSource?.sourceTable || row.id; const tableName = row.tableDataSource?.sourceTable || ""; return (
handleToggleTable(row.id)} > {}} // onClick에서 처리 className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500" />

{tableTitle}

테이블: {tableName} | ID: {row.id.slice(-10)}

); })}
{selectedRefs.length > 0 && (

선택된 테이블: {selectedRefs.length}개 (특정 테이블만 참조)

)}
); } // 🆕 v3.12: 연동 저장 설정 섹션 function SyncSaveConfigSection({ row, allTables, onUpdateRow, }: { row: CardContentRowConfig; allTables: { tableName: string; displayName?: string }[]; onUpdateRow: (updates: Partial) => void; }) { const syncSaves = row.tableCrud?.syncSaves || []; const sourceTable = row.tableDataSource?.sourceTable || ""; // 연동 저장 추가 const addSyncSave = () => { const newSyncSave: SyncSaveConfig = { id: `sync-${Date.now()}`, enabled: true, sourceColumn: "", aggregationType: "sum", targetTable: "", targetColumn: "", joinKey: { sourceField: "", targetField: "id", }, }; onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, syncSaves: [...syncSaves, newSyncSave], }, }); }; // 연동 저장 삭제 const removeSyncSave = (index: number) => { const newSyncSaves = [...syncSaves]; newSyncSaves.splice(index, 1); onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, syncSaves: newSyncSaves, }, }); }; // 연동 저장 업데이트 const updateSyncSave = (index: number, updates: Partial) => { const newSyncSaves = [...syncSaves]; newSyncSaves[index] = { ...newSyncSaves[index], ...updates }; onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, syncSaves: newSyncSaves, }, }); }; return (
{syncSaves.length === 0 ? (

연동 저장 설정이 없습니다. 추가 버튼을 눌러 설정하세요.

) : (
{syncSaves.map((sync, index) => ( updateSyncSave(index, updates)} onRemove={() => removeSyncSave(index)} /> ))}
)}
); } // 🆕 v3.12: 개별 연동 저장 설정 아이템 function SyncSaveConfigItem({ sync, index, sourceTable, allTables, onUpdate, onRemove, }: { sync: SyncSaveConfig; index: number; sourceTable: string; allTables: { tableName: string; displayName?: string }[]; onUpdate: (updates: Partial) => void; onRemove: () => void; }) { return (
{/* 헤더 */}
onUpdate({ enabled: checked })} className="scale-[0.6]" /> 연동 {index + 1}
{/* 소스 설정 */}
onUpdate({ sourceColumn: value })} placeholder="컬럼 선택" />
{/* 대상 설정 */}
onUpdate({ targetColumn: value })} placeholder="컬럼 선택" />
{/* 조인 키 설정 */}
onUpdate({ joinKey: { ...sync.joinKey, sourceField: value } })} placeholder="예: sales_order_id" />
onUpdate({ joinKey: { ...sync.joinKey, targetField: value } })} placeholder="예: id" />
{/* 설정 요약 */} {sync.sourceColumn && sync.targetTable && sync.targetColumn && (

{sourceTable}.{sync.sourceColumn}의 {sync.aggregationType.toUpperCase()} 값을{" "} {sync.targetTable}.{sync.targetColumn}에 저장

)}
); } // 🆕 v3.13: 행 추가 시 자동 채번 설정 섹션 function RowNumberingConfigSection({ row, onUpdateRow, }: { row: CardContentRowConfig; onUpdateRow: (updates: Partial) => void; }) { const [numberingRules, setNumberingRules] = useState<{ id: string; name: string; code: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const rowNumbering = row.tableCrud?.rowNumbering; const tableColumns = row.tableColumns || []; // 채번 규칙 목록 로드 (옵션설정 > 코드설정에서 등록된 전체 목록) useEffect(() => { const loadNumberingRules = async () => { setIsLoading(true); try { const { getNumberingRules } = await import("@/lib/api/numberingRule"); const response = await getNumberingRules(); if (response.success && response.data) { setNumberingRules(response.data.map((rule: any, index: number) => ({ id: String(rule.ruleId || rule.id || `rule-${index}`), name: rule.ruleName || rule.name || "이름 없음", code: rule.ruleId || rule.code || "", }))); } } catch (error) { console.error("채번 규칙 로드 실패:", error); setNumberingRules([]); } finally { setIsLoading(false); } }; loadNumberingRules(); }, []); // 채번 설정 업데이트 const updateRowNumbering = (updates: Partial) => { const currentNumbering = row.tableCrud?.rowNumbering || { enabled: false, targetColumn: "", numberingRuleId: "", }; onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, rowNumbering: { ...currentNumbering, ...updates, }, }, }); }; return (
updateRowNumbering({ enabled: checked })} className="scale-90" />

"추가" 버튼 클릭 시 지정한 컬럼에 자동으로 번호를 생성합니다. (옵션설정 > 코드설정에서 등록한 채번 규칙 사용)

{rowNumbering?.enabled && (
{/* 대상 컬럼 선택 */}

채번 결과가 저장될 컬럼 (수정 가능 여부는 컬럼 설정에서 조절)

{/* 채번 규칙 선택 */}
{numberingRules.length === 0 && !isLoading && (

등록된 채번 규칙이 없습니다. 옵션설정 > 코드설정에서 추가하세요.

)}
{/* 설정 요약 */} {rowNumbering.targetColumn && rowNumbering.numberingRuleId && (
"추가" 클릭 시 {rowNumbering.targetColumn} 컬럼에 자동 채번
)}
)}
); } // 🆕 레이아웃 설정 전용 모달 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 && ( <>
onUpdateRow({ tableDataSource: { ...row.tableDataSource!, sourceTable: value } })} />
{/* 조인 조건 설정 */}

두 테이블을 연결하는 키를 설정합니다

{(row.tableDataSource?.joinConditions || []).map((condition, conditionIndex) => (
조인 {conditionIndex + 1}
{ const newConditions = [...(row.tableDataSource?.joinConditions || [])]; newConditions[conditionIndex] = { ...condition, sourceKey: value }; onUpdateRow({ tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions } }); }} placeholder="예: sales_order_id" />

외부 테이블의 컬럼

{ const newConditions = [...(row.tableDataSource?.joinConditions || [])]; newConditions[conditionIndex] = { ...condition, referenceKey: value }; onUpdateRow({ tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions } }); }} placeholder="예: id" />

메인 테이블의 컬럼

{row.tableDataSource?.sourceTable}.{condition.sourceKey} = {dataSourceTable}.{condition.referenceKey}

외부 테이블에서 메인 테이블의 값과 일치하는 데이터를 가져옵니다

))}
{/* 필터 설정 */}

특정 조건으로 데이터를 제외합니다

{ onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: { enabled: checked, filterField: "", filterType: "notEquals", referenceField: "", referenceSource: "representativeData", }, }, }); }} className="scale-75" />
{row.tableDataSource?.filterConfig?.enabled && (
{ onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: { ...row.tableDataSource!.filterConfig!, filterField: value, }, }, }); }} placeholder="예: order_no" />

외부 테이블에서 비교할 컬럼

{ onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: { ...row.tableDataSource!.filterConfig!, referenceField: value, }, }, }); }} placeholder="예: order_no" />

현재 선택한 행의 컬럼

{row.tableDataSource?.sourceTable}.{row.tableDataSource?.filterConfig?.filterField} != 현재행.{row.tableDataSource?.filterConfig?.referenceField}

{row.tableDataSource?.filterConfig?.filterType === "notEquals" ? "현재 선택한 행과 다른 데이터만 표시합니다" : "현재 선택한 행과 같은 데이터만 표시합니다"}

)}
)}
{/* CRUD 설정 */}
onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, }) } className="scale-90" />
onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, }) } className="scale-90" />
onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, }) } className="scale-90" />
{row.tableCrud?.allowDelete && (
onUpdateRow({ tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } }, }) } className="scale-75" />
)}
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */} {row.tableCrud?.allowCreate && ( )} {/* 🆕 v3.12: 연동 저장 설정 */} {/* 테이블 컬럼 목록 */}
{(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 ? "예" : "아니오"}
onUpdateTableColumn(colIndex, { hidden: checked })} className="scale-75" /> {col.hidden ? "예" : "아니오"}
))}
)}
)}
); } // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) // 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 function AggregationConfigItem({ agg, index, sourceTable, allTables, existingAggregations, onUpdate, onRemove, }: { agg: AggregationConfig; index: number; sourceTable: string; allTables: { tableName: string; displayName?: string }[]; existingAggregations: AggregationConfig[]; // 연산식에서 참조할 수 있는 기존 집계들 onUpdate: (updates: Partial) => void; onRemove: () => void; }) { const [localLabel, setLocalLabel] = useState(agg.label || ""); const [localResultField, setLocalResultField] = useState(agg.resultField || ""); const [localFormula, setLocalFormula] = useState(agg.formula || ""); // agg 변경 시 로컬 상태 동기화 useEffect(() => { setLocalLabel(agg.label || ""); setLocalResultField(agg.resultField || ""); setLocalFormula(agg.formula || ""); }, [agg.label, agg.resultField, agg.formula]); // 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지) const referenceableAggregations = existingAggregations.slice(0, index); // sourceType 기본값 처리 const currentSourceType = agg.sourceType || "column"; return (
{currentSourceType === "formula" ? "가상" : "집계"} {index + 1}
{/* 집계 타입 선택 */}
{/* === 컬럼 집계 설정 === */} {currentSourceType === "column" && ( <> {/* 테이블 선택 */}

기본 테이블 외 다른 테이블도 선택 가능

{/* 컬럼 선택 */}
onUpdate({ sourceField: value })} placeholder="합계할 필드" />
{/* 집계 함수 */}
)} {/* === 가상 집계 (연산식) 설정 === */} {currentSourceType === "formula" && ( { setLocalFormula(newFormula); onUpdate({ formula: newFormula }); }} /> )} {/* 공통: 라벨 및 결과 필드명 */}
setLocalLabel(e.target.value)} onBlur={() => onUpdate({ label: localLabel })} onKeyDown={(e) => { if (e.key === "Enter") { onUpdate({ label: localLabel }); } }} placeholder="총수주잔량" className="h-6 text-[10px]" />
setLocalResultField(e.target.value)} onBlur={() => onUpdate({ resultField: localResultField })} onKeyDown={(e) => { if (e.key === "Enter") { onUpdate({ resultField: localResultField }); } }} placeholder="total_balance_qty" className="h-6 text-[10px] font-mono" />
{/* 🆕 v3.9: 저장 설정 */}
); } // 🆕 v3.9: 집계 저장 설정 섹션 function AggregationSaveConfigSection({ agg, sourceTable, allTables, onUpdate, }: { agg: AggregationConfig; sourceTable: string; allTables: { tableName: string; displayName?: string }[]; onUpdate: (updates: Partial) => void; }) { const saveConfig = agg.saveConfig || { enabled: false, autoSave: false, targetTable: "", targetColumn: "", joinKey: { sourceField: "", targetField: "" } }; const updateSaveConfig = (updates: Partial) => { onUpdate({ saveConfig: { ...saveConfig, ...updates, }, }); }; return (
updateSaveConfig({ enabled: checked })} className="scale-[0.6]" />
{saveConfig.enabled && (
{/* 자동 저장 옵션 */}

레이아웃에 없어도 저장

updateSaveConfig({ autoSave: checked })} className="scale-[0.6]" />
{/* 대상 테이블 */}
{/* 대상 컬럼 */}
updateSaveConfig({ targetColumn: value })} placeholder="컬럼 선택" />
{/* 조인 키 설정 */}
카드 키 (현재 카드 데이터) updateSaveConfig({ joinKey: { ...saveConfig.joinKey, sourceField: value }, }) } placeholder="카드 키 선택" />
대상 키 (업데이트할 레코드 식별) updateSaveConfig({ joinKey: { ...saveConfig.joinKey, targetField: value }, }) } placeholder="대상 키 선택" />
{/* 설정 요약 */} {saveConfig.targetTable && saveConfig.targetColumn && (
저장 경로: {saveConfig.autoSave && ( 자동 )}
{saveConfig.targetTable}.{saveConfig.targetColumn}
{saveConfig.joinKey?.sourceField && saveConfig.joinKey?.targetField && (
조인: {saveConfig.joinKey.sourceField} → {saveConfig.joinKey.targetField}
)}
)}
)}
); } // 테이블 선택기 (Combobox) - 240px 최적화 function TableSelector({ value, onChange, allTables, placeholder = "테이블 선택", }: { value: string; onChange: (value: string) => void; allTables?: { tableName: string; displayName?: string }[]; placeholder?: string; }) { const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { // allTables가 전달되면 API 호출 없이 사용 if (allTables && allTables.length > 0) { setTables(allTables); return; } const loadTables = async () => { setIsLoading(true); try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { // API 응답이 배열인 경우와 객체인 경우 모두 처리 const tableData = Array.isArray(response.data) ? response.data : (response.data as any).tables || response.data || []; setTables(tableData); } } catch (error) { console.error("테이블 로드 실패:", error); setTables([]); } finally { setIsLoading(false); } }; loadTables(); }, [allTables]); const selectedTable = (tables || []).find((t) => t.tableName === value); const displayText = selectedTable ? selectedTable.tableName : placeholder; return ( 없음 {tables.map((table) => ( { onChange(table.tableName); setOpen(false); }} className="text-[10px] py-1" > {table.tableName} ))} ); } // 모듈 레벨에서 탭 상태 유지 (컴포넌트 리마운트 시에도 유지) let persistedActiveTab = "basic"; export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenModalConfigPanelProps) { const [localConfig, setLocalConfig] = useState>(() => ({ dataSource: { sourceTable: "" }, saveMode: "all", cardSpacing: "24px", showCardBorder: true, showCardTitle: true, cardTitle: "카드 {index}", grouping: { enabled: false, groupByField: "", aggregations: [] }, contentRows: [], // 🆕 v3: 자유 레이아웃 // 레거시 호환 cardMode: "simple", cardLayout: [], tableLayout: { headerRows: [], tableColumns: [] }, ...config, })); const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]); // 집계 설정 모달 상태 const [aggregationModalOpen, setAggregationModalOpen] = useState(false); // 레이아웃 설정 모달 상태 const [layoutModalOpen, setLayoutModalOpen] = useState(false); // 탭 상태 유지 (모듈 레벨 변수와 동기화) const [activeTab, setActiveTab] = useState(persistedActiveTab); // 탭 변경 시 모듈 레벨 변수도 업데이트 const handleTabChange = (tab: string) => { persistedActiveTab = tab; setActiveTab(tab); }; // 테이블 목록 로드 useEffect(() => { const loadTables = async () => { try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { // API 응답이 배열인 경우와 객체인 경우 모두 처리 const tableData = Array.isArray(response.data) ? response.data : (response.data as any).tables || response.data || []; setAllTables(tableData); } } catch (error) { console.error("테이블 로드 실패:", error); } }; loadTables(); }, []); // Debounced update for input fields const updateConfigDebounced = useCallback( (updates: Partial) => { const timeoutId = setTimeout(() => { setLocalConfig((prev) => { const newConfig = { ...prev, ...updates }; onChange(newConfig); return newConfig; }); }, 500); return () => clearTimeout(timeoutId); }, [onChange] ); // Immediate update for select/switch fields // requestAnimationFrame을 사용하여 React 렌더링 사이클 이후에 onChange 호출 const updateConfig = useCallback((updates: Partial) => { setLocalConfig((prev) => { const newConfig = { ...prev, ...updates }; // 비동기로 onChange 호출하여 현재 렌더링 사이클 완료 후 실행 requestAnimationFrame(() => { onChange(newConfig); }); return newConfig; }); }, [onChange]); // === 그룹핑 관련 함수 === const updateGrouping = (updates: Partial) => { updateConfig({ grouping: { ...localConfig.grouping, enabled: localConfig.grouping?.enabled ?? false, groupByField: localConfig.grouping?.groupByField ?? "", aggregations: localConfig.grouping?.aggregations ?? [], ...updates, }, }); }; const addAggregation = (sourceType: "column" | "formula" = "column") => { const newAgg: AggregationConfig = { sourceType, // column 타입 기본값 ...(sourceType === "column" && { sourceTable: localConfig.dataSource?.sourceTable || "", sourceField: "", type: "sum" as const, }), // formula 타입 기본값 ...(sourceType === "formula" && { formula: "", }), resultField: `agg_${Date.now()}`, label: "", }; updateGrouping({ aggregations: [...(localConfig.grouping?.aggregations || []), newAgg], }); }; const removeAggregation = (index: number) => { const newAggs = [...(localConfig.grouping?.aggregations || [])]; newAggs.splice(index, 1); updateGrouping({ aggregations: newAggs }); }; const updateAggregation = (index: number, updates: Partial) => { const newAggs = [...(localConfig.grouping?.aggregations || [])]; newAggs[index] = { ...newAggs[index], ...updates }; updateGrouping({ aggregations: newAggs }); }; // === 테이블 레이아웃 관련 함수 === const updateTableLayout = (updates: Partial) => { updateConfig({ tableLayout: { ...localConfig.tableLayout, headerRows: localConfig.tableLayout?.headerRows ?? [], tableColumns: localConfig.tableLayout?.tableColumns ?? [], ...updates, }, }); }; const addTableColumn = () => { const newCol: TableColumnConfig = { id: `tcol-${Date.now()}`, field: "", label: "", type: "text", width: "auto", editable: false, }; updateTableLayout({ tableColumns: [...(localConfig.tableLayout?.tableColumns || []), newCol], }); }; const removeTableColumn = (index: number) => { const newCols = [...(localConfig.tableLayout?.tableColumns || [])]; newCols.splice(index, 1); updateTableLayout({ tableColumns: newCols }); }; const updateTableColumn = (index: number, updates: Partial) => { const newCols = [...(localConfig.tableLayout?.tableColumns || [])]; newCols[index] = { ...newCols[index], ...updates }; updateTableLayout({ tableColumns: newCols }); }; // === 헤더 행 관련 함수 (simple 모드와 동일) === const addHeaderRow = () => { const newRow: CardRowConfig = { id: `hrow-${Date.now()}`, columns: [], gap: "16px", layout: "horizontal", }; updateTableLayout({ headerRows: [...(localConfig.tableLayout?.headerRows || []), newRow], }); }; const removeHeaderRow = (rowIndex: number) => { const newRows = [...(localConfig.tableLayout?.headerRows || [])]; newRows.splice(rowIndex, 1); updateTableLayout({ headerRows: newRows }); }; const updateHeaderRow = (rowIndex: number, updates: Partial) => { const newRows = [...(localConfig.tableLayout?.headerRows || [])]; newRows[rowIndex] = { ...newRows[rowIndex], ...updates }; updateTableLayout({ headerRows: newRows }); }; const addHeaderColumn = (rowIndex: number) => { const newRows = [...(localConfig.tableLayout?.headerRows || [])]; const newColumn: CardColumnConfig = { id: `hcol-${Date.now()}`, field: "", label: "", type: "text", width: "auto", editable: false, }; newRows[rowIndex].columns.push(newColumn); updateTableLayout({ headerRows: newRows }); }; const removeHeaderColumn = (rowIndex: number, colIndex: number) => { const newRows = [...(localConfig.tableLayout?.headerRows || [])]; newRows[rowIndex].columns.splice(colIndex, 1); updateTableLayout({ headerRows: newRows }); }; const updateHeaderColumn = (rowIndex: number, colIndex: number, updates: Partial) => { const newRows = [...(localConfig.tableLayout?.headerRows || [])]; newRows[rowIndex].columns[colIndex] = { ...newRows[rowIndex].columns[colIndex], ...updates }; updateTableLayout({ headerRows: newRows }); }; // === 🆕 v3: contentRows 관련 함수 === const addContentRow = (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 } : {}), }; updateConfig({ contentRows: [...(localConfig.contentRows || []), newRow], }); }; const removeContentRow = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; newRows.splice(rowIndex, 1); updateConfig({ contentRows: newRows }); }; const updateContentRow = (rowIndex: number, updates: Partial) => { const newRows = [...(localConfig.contentRows || [])]; newRows[rowIndex] = { ...newRows[rowIndex], ...updates }; updateConfig({ contentRows: newRows }); }; // contentRow 내 컬럼 관리 (header/fields 타입) const addContentRowColumn = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; const newColumn: CardColumnConfig = { id: `col-${Date.now()}`, field: "", label: "", type: "text", width: "auto", editable: false, }; newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newColumn]; updateConfig({ contentRows: newRows }); }; const removeContentRowColumn = (rowIndex: number, colIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; newRows[rowIndex].columns?.splice(colIndex, 1); updateConfig({ contentRows: newRows }); }; const updateContentRowColumn = (rowIndex: number, colIndex: number, updates: Partial) => { const newRows = [...(localConfig.contentRows || [])]; if (newRows[rowIndex].columns) { newRows[rowIndex].columns![colIndex] = { ...newRows[rowIndex].columns![colIndex], ...updates }; } updateConfig({ contentRows: newRows }); }; // contentRow 내 집계 필드 관리 (aggregation 타입) const addContentRowAggField = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; const newAggField: AggregationDisplayConfig = { sourceType: "aggregation", aggregationResultField: "", label: "", }; newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField]; updateConfig({ contentRows: newRows }); }; const removeContentRowAggField = (rowIndex: number, fieldIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1); updateConfig({ contentRows: newRows }); }; const updateContentRowAggField = (rowIndex: number, fieldIndex: number, updates: Partial) => { const newRows = [...(localConfig.contentRows || [])]; if (newRows[rowIndex].aggregationFields) { newRows[rowIndex].aggregationFields![fieldIndex] = { ...newRows[rowIndex].aggregationFields![fieldIndex], ...updates, }; } updateConfig({ contentRows: newRows }); }; // 🆕 집계 필드 순서 변경 const moveContentRowAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => { const newRows = [...(localConfig.contentRows || [])]; 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]]; updateConfig({ contentRows: newRows }); }; // contentRow 내 테이블 컬럼 관리 (table 타입) const addContentRowTableColumn = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; const newCol: TableColumnConfig = { id: `tcol-${Date.now()}`, field: "", label: "", type: "text", editable: false, }; newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol]; updateConfig({ contentRows: newRows }); }; const removeContentRowTableColumn = (rowIndex: number, colIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; newRows[rowIndex].tableColumns?.splice(colIndex, 1); updateConfig({ contentRows: newRows }); }; const updateContentRowTableColumn = (rowIndex: number, colIndex: number, updates: Partial) => { const newRows = [...(localConfig.contentRows || [])]; if (newRows[rowIndex].tableColumns) { newRows[rowIndex].tableColumns![colIndex] = { ...newRows[rowIndex].tableColumns![colIndex], ...updates }; } updateConfig({ contentRows: newRows }); }; // 테이블 컬럼 순서 변경 const moveContentRowTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => { const newRows = [...(localConfig.contentRows || [])]; const columns = newRows[rowIndex].tableColumns; if (!columns) return; const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1; if (newIndex < 0 || newIndex >= columns.length) return; // 컬럼 위치 교환 const newColumns = [...columns]; [newColumns[colIndex], newColumns[newIndex]] = [newColumns[newIndex], newColumns[colIndex]]; newRows[rowIndex].tableColumns = newColumns; 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 = { id: `row-${Date.now()}`, columns: [], gap: "16px", layout: "horizontal", }; updateConfig({ cardLayout: [...(localConfig.cardLayout || []), newRow], }); }; const removeRow = (rowIndex: number) => { const newLayout = [...(localConfig.cardLayout || [])]; newLayout.splice(rowIndex, 1); updateConfig({ cardLayout: newLayout }); }; const updateRow = (rowIndex: number, updates: Partial) => { const newLayout = [...(localConfig.cardLayout || [])]; newLayout[rowIndex] = { ...newLayout[rowIndex], ...updates }; updateConfig({ cardLayout: newLayout }); }; const addColumn = (rowIndex: number) => { const newLayout = [...(localConfig.cardLayout || [])]; const newColumn: CardColumnConfig = { id: `col-${Date.now()}`, field: "", label: "", type: "text", width: "auto", editable: true, required: false, }; newLayout[rowIndex].columns.push(newColumn); updateConfig({ cardLayout: newLayout }); }; const removeColumn = (rowIndex: number, colIndex: number) => { const newLayout = [...(localConfig.cardLayout || [])]; newLayout[rowIndex].columns.splice(colIndex, 1); updateConfig({ cardLayout: newLayout }); }; const updateColumn = (rowIndex: number, colIndex: number, updates: Partial) => { const newLayout = [...(localConfig.cardLayout || [])]; newLayout[rowIndex].columns[colIndex] = { ...newLayout[rowIndex].columns[colIndex], ...updates, }; updateConfig({ cardLayout: newLayout }); }; return (
기본 소스 그룹 레이아웃 {/* === 기본 설정 탭 === */}

카드 설정

{/* 카드 제목 표시 여부 */}
updateConfig({ showCardTitle: checked })} className="scale-75" />
{/* 카드 제목 설정 (표시할 때만) */} {localConfig.showCardTitle && (
{ setLocalConfig((prev) => ({ ...prev, cardTitle: value })); updateConfig({ cardTitle: value }); }} />
)}
updateConfig({ showCardBorder: checked })} className="scale-75" />
{/* === 데이터 소스 탭 === */}

데이터 소스

updateConfig({ dataSource: { ...localConfig.dataSource, sourceTable: value, }, }) } />
{ setLocalConfig((prev) => ({ ...prev, dataSource: { ...prev.dataSource, sourceTable: prev.dataSource?.sourceTable || "", filterField: e.target.value, }, })); updateConfigDebounced({ dataSource: { ...localConfig.dataSource, sourceTable: localConfig.dataSource?.sourceTable || "", filterField: e.target.value, }, }); }} placeholder="selectedIds" className="h-7 text-[10px]" />

formData에서 가져올 필드

{/* === 그룹핑 설정 탭 === */}

그룹핑

updateGrouping({ enabled: checked })} className="scale-75" />
{localConfig.grouping?.enabled && ( <>
updateGrouping({ groupByField: value })} placeholder="예: part_code" />

같은 값을 가진 행들을 하나의 카드로 묶음

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

집계 설정이 없습니다

)}
)}
{/* === 레이아웃 설정 탭 === */}

레이아웃 행

{/* 현재 레이아웃 요약 */} {(localConfig.contentRows || []).length > 0 ? (
{(localConfig.contentRows || []).map((row, index) => { const getRowTypeColor = (type: CardContentRowConfig["type"]) => { switch (type) { case "header": return "bg-blue-50 border-blue-200"; case "aggregation": return "bg-orange-50 border-orange-200"; case "table": return "bg-green-50 border-green-200"; case "fields": return "bg-purple-50 border-purple-200"; default: return "bg-gray-50 border-gray-200"; } }; const getRowTypeLabel = (type: CardContentRowConfig["type"]) => { switch (type) { case "header": return "헤더"; case "aggregation": return "집계"; case "table": return "테이블"; case "fields": return "필드"; default: return type; } }; const getRowInfo = () => { if (row.type === "header" || row.type === "fields") { return `${(row.columns || []).length}개 컬럼`; } if (row.type === "aggregation") { return `${(row.aggregationFields || []).length}개 필드`; } if (row.type === "table") { return `${(row.tableColumns || []).length}개 컬럼${row.tableDataSource?.enabled ? " (외부)" : ""}`; } return ""; }; return (
{index + 1} {getRowTypeLabel(row.type)}
{getRowInfo()}
); })}
) : (

레이아웃 행이 없습니다. 설정 열기를 클릭하여 추가하세요.

)}
{/* 집계 설정 모달 */} { updateGrouping({ aggregations: newAggregations }); }} /> {/* 레이아웃 설정 모달 */} { updateConfig({ contentRows: newContentRows }); }} />
); } // === 🆕 v3: 콘텐츠 행 설정 섹션 === function ContentRowConfigSection({ row, rowIndex, totalRows, allTables, dataSourceTable, aggregations, onUpdateRow, onRemoveRow, onMoveRow, onAddColumn, onRemoveColumn, onUpdateColumn, onAddAggField, onRemoveAggField, onUpdateAggField, onMoveAggField, onAddTableColumn, onRemoveTableColumn, onUpdateTableColumn, onMoveTableColumn, }: { 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; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localTableTitle, setLocalTableTitle] = useState(row.tableTitle || ""); useEffect(() => { setLocalTableTitle(row.tableTitle || ""); }, [row.tableTitle]); const handleTableTitleBlur = () => { if (localTableTitle !== row.tableTitle) { onUpdateRow({ tableTitle: localTableTitle }); } }; // 행 타입별 색상 const typeColors = { header: "bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800", aggregation: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800", table: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800", fields: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800", }; const typeLabels = { header: "헤더", aggregation: "집계", table: "테이블", fields: "필드", }; const typeBadgeColors = { header: "bg-purple-100 text-purple-700", aggregation: "bg-orange-100 text-orange-700", table: "bg-blue-100 text-blue-700", fields: "bg-green-100 text-green-700", }; return (
{/* 행 헤더 */}
{/* 순서 변경 버튼 */}
행 {rowIndex + 1}: {typeLabels[row.type]}
{/* 타입 변경 */}
{/* 헤더/필드 타입 설정 */} {(row.type === "header" || row.type === "fields") && (
{/* 컬럼 목록 */}
{(row.columns || []).map((col, colIndex) => ( onUpdateColumn(colIndex, updates)} onRemove={() => onRemoveColumn(colIndex)} /> ))} {(row.columns || []).length === 0 && (
컬럼 추가
)}
)} {/* 집계 타입 설정 */} {row.type === "aggregation" && (
{row.aggregationLayout === "grid" && (
)}
{/* 집계 필드 목록 */}
{aggregations.length === 0 && (

먼저 그룹 탭에서 집계 설정을 추가하세요

)} {(row.aggregationFields || []).map((field, fieldIndex) => (
{/* 순서 변경 버튼 */}
집계 {fieldIndex + 1}
onUpdateAggField(fieldIndex, { label: e.target.value })} placeholder="총수주잔량" className="h-6 text-[10px]" />
))} {(row.aggregationFields || []).length === 0 && aggregations.length > 0 && (
집계 필드 추가
)}
)} {/* 테이블 타입 설정 */} {row.type === "table" && (
setLocalTableTitle(e.target.value)} onBlur={handleTableTitleBlur} onKeyDown={(e) => e.key === "Enter" && handleTableTitleBlur()} placeholder="선택사항" className="h-6 text-[10px]" />
onUpdateRow({ showTableHeader: checked })} className="scale-[0.6]" />
{/* 외부 테이블 데이터 소스 설정 */}
onUpdateRow({ tableDataSource: checked ? { enabled: true, sourceTable: "", joinConditions: [] } : undefined, }) } className="scale-[0.6]" />
{row.tableDataSource?.enabled && (
외부 테이블 키 onUpdateRow({ tableDataSource: { ...row.tableDataSource!, joinConditions: [ { ...row.tableDataSource?.joinConditions?.[0], sourceKey: value, referenceKey: row.tableDataSource?.joinConditions?.[0]?.referenceKey || "", }, ], }, }) } placeholder="키 선택" />
카드 데이터 키 onUpdateRow({ tableDataSource: { ...row.tableDataSource!, joinConditions: [ { ...row.tableDataSource?.joinConditions?.[0], sourceKey: row.tableDataSource?.joinConditions?.[0]?.sourceKey || "", referenceKey: value, }, ], }, }) } placeholder="키 선택" />
onUpdateRow({ tableDataSource: { ...row.tableDataSource!, orderBy: value ? { column: value, direction: row.tableDataSource?.orderBy?.direction || "desc" } : undefined, }, }) } placeholder="선택" />
{/* 🆕 추가 조인 테이블 설정 */}

소스 테이블에 없는 컬럼을 다른 테이블에서 조인하여 가져옵니다

{(row.tableDataSource?.additionalJoins || []).map((join, joinIndex) => (
조인 {joinIndex + 1}
{/* 조인 테이블 선택 */}
{ const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; newJoins[joinIndex] = { ...join, joinTable: value }; onUpdateRow({ tableDataSource: { ...row.tableDataSource!, additionalJoins: newJoins, }, }); }} placeholder="테이블 선택" />
{/* 조인 조건 */} {join.joinTable && (
{ const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; newJoins[joinIndex] = { ...join, sourceKey: value }; onUpdateRow({ tableDataSource: { ...row.tableDataSource!, additionalJoins: newJoins, }, }); }} placeholder="소스 키" />
=
{ const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; newJoins[joinIndex] = { ...join, targetKey: value }; onUpdateRow({ tableDataSource: { ...row.tableDataSource!, additionalJoins: newJoins, }, }); }} placeholder="조인 키" />

{row.tableDataSource?.sourceTable}.{join.sourceKey || "?"} = {join.joinTable}.{join.targetKey || "?"}

)}
))} {(row.tableDataSource?.additionalJoins || []).length === 0 && (
조인 테이블 없음 (소스 테이블 컬럼만 사용)
)}
{/* 🆕 v3.4: 필터 설정 */}
onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: checked ? { enabled: true, filterField: "", filterType: "equals", referenceField: "", referenceSource: "representativeData", } : undefined, }, }) } className="scale-[0.6]" />

그룹 내 데이터를 특정 조건으로 필터링합니다 (같은 값만 / 다른 값만)

{row.tableDataSource?.filterConfig?.enabled && (
{/* 필터 타입 */}
{/* 필터 필드 */}
onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: { ...row.tableDataSource!.filterConfig!, filterField: value, }, }, }) } placeholder="필터링할 컬럼 선택" />

이 컬럼 값을 기준으로 필터링합니다

{/* 비교 기준 소스 */}
{/* 비교 기준 필드 */}
{row.tableDataSource.filterConfig.referenceSource === "representativeData" ? ( onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: { ...row.tableDataSource!.filterConfig!, referenceField: value, }, }, }) } placeholder="비교할 필드 선택" /> ) : ( onUpdateRow({ tableDataSource: { ...row.tableDataSource!, filterConfig: { ...row.tableDataSource!.filterConfig!, referenceField: e.target.value, }, }, }) } placeholder="formData 필드명 (예: selectedOrderNo)" className="h-6 text-[10px]" /> )}

이 값과 비교하여 필터링합니다

{/* 필터 조건 미리보기 */} {row.tableDataSource.filterConfig.filterField && row.tableDataSource.filterConfig.referenceField && (
조건: {row.tableDataSource.sourceTable}.{row.tableDataSource.filterConfig.filterField} {row.tableDataSource.filterConfig.filterType === "equals" ? " = " : " != "} {row.tableDataSource.filterConfig.referenceSource === "representativeData" ? `카드.${row.tableDataSource.filterConfig.referenceField}` : `formData.${row.tableDataSource.filterConfig.referenceField}` }
)}
)}
)}
{/* CRUD 설정 */}
onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, }) } className="scale-[0.5]" />
onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, }) } className="scale-[0.5]" />
onUpdateRow({ tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, }) } className="scale-[0.5]" />
{row.tableCrud?.allowDelete && (
onUpdateRow({ tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } }, }) } className="scale-[0.5]" />
)}
{/* 🆕 v3.12: 연동 저장 설정 */} {/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */} {row.tableCrud?.allowCreate && ( )} {/* 테이블 컬럼 목록 */}
{(row.tableColumns || []).map((col, colIndex) => ( onUpdateTableColumn(colIndex, updates)} onRemove={() => onRemoveTableColumn(colIndex)} onMoveUp={() => onMoveTableColumn?.(colIndex, "up")} onMoveDown={() => onMoveTableColumn?.(colIndex, "down")} isFirst={colIndex === 0} isLast={colIndex === (row.tableColumns || []).length - 1} /> ))} {(row.tableColumns || []).length === 0 && (
테이블 컬럼 추가
)}
)}
); } // === (레거시) 행 설정 섹션 === function RowConfigSection({ row, rowIndex, allTables, dataSourceTable, aggregations, onUpdateRow, onRemoveRow, onAddColumn, onRemoveColumn, onUpdateColumn, isHeader = false, }: { row: CardRowConfig; rowIndex: number; allTables: { tableName: string; displayName?: string }[]; dataSourceTable: string; aggregations: AggregationConfig[]; onUpdateRow: (updates: Partial) => void; onRemoveRow: () => void; onAddColumn: () => void; onRemoveColumn: (colIndex: number) => void; onUpdateColumn: (colIndex: number, updates: Partial) => void; isHeader?: boolean; }) { return (
행 {rowIndex + 1} ({row.columns.length}개)
{/* 행 설정 */}
{isHeader && (
)}
{/* 컬럼 목록 */}
{row.columns.map((col, colIndex) => ( onUpdateColumn(colIndex, updates)} onRemove={() => onRemoveColumn(colIndex)} /> ))} {row.columns.length === 0 && (
컬럼 추가
)}
); } // === 컬럼 설정 섹션 (로컬 상태로 입력 필드 관리) === function ColumnConfigSection({ col, colIndex, allTables, dataSourceTable, aggregations, onUpdate, onRemove, }: { col: CardColumnConfig; colIndex: number; allTables: { tableName: string; displayName?: string }[]; dataSourceTable: string; aggregations: AggregationConfig[]; onUpdate: (updates: Partial) => void; onRemove: () => void; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localField, setLocalField] = useState(col.field || ""); const [localLabel, setLocalLabel] = useState(col.label || ""); useEffect(() => { setLocalField(col.field || ""); setLocalLabel(col.label || ""); }, [col.field, col.label]); const handleFieldBlur = () => { if (localField !== col.field) { onUpdate({ field: localField }); } }; const handleLabelBlur = () => { if (localLabel !== col.label) { onUpdate({ label: localLabel }); } }; return (
컬럼 {colIndex + 1}
{/* 기본 설정 */}
setLocalField(e.target.value)} onBlur={handleFieldBlur} onKeyDown={(e) => e.key === "Enter" && handleFieldBlur()} placeholder="field_name" className="h-6 text-[10px]" />
setLocalLabel(e.target.value)} onBlur={handleLabelBlur} onKeyDown={(e) => e.key === "Enter" && handleLabelBlur()} placeholder="표시명" className="h-6 text-[10px]" />
{/* 집계값 타입일 때 */} {col.type === "aggregation" && aggregations.length > 0 && (
)}
{/* 편집/필수 설정 */}
onUpdate({ editable: checked })} className="scale-[0.6]" />
onUpdate({ required: checked })} className="scale-[0.6]" />
{/* 데이터 소스 설정 */} {col.type !== "aggregation" && (
{col.sourceConfig?.type === "direct" && ( onUpdate({ sourceConfig: { ...col.sourceConfig, type: "direct", sourceColumn: value, } as ColumnSourceConfig, }) } /> )} {col.sourceConfig?.type === "join" && (
onUpdate({ sourceConfig: { ...col.sourceConfig, type: "join", joinKey: value, } as ColumnSourceConfig, }) } />
)}
)} {/* 데이터 타겟 설정 */}
{col.targetConfig?.targetTable && ( onUpdate({ targetConfig: { ...col.targetConfig, targetColumn: value, } as ColumnTargetConfig, }) } /> )}
); } // === 테이블 컬럼 설정 섹션 (로컬 상태로 입력 필드 관리) === function TableColumnConfigSection({ col, colIndex, allTables, dataSourceTable, additionalJoins, onUpdate, onRemove, onMoveUp, onMoveDown, isFirst, isLast, }: { col: TableColumnConfig; colIndex: number; allTables: { tableName: string; displayName?: string }[]; dataSourceTable: string; additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[]; onUpdate: (updates: Partial) => void; onRemove: () => void; onMoveUp?: () => void; onMoveDown?: () => void; isFirst?: boolean; isLast?: boolean; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localLabel, setLocalLabel] = useState(col.label || ""); const [localWidth, setLocalWidth] = useState(col.width || ""); // 선택된 테이블 (소스 테이블 또는 조인 테이블) const selectedTable = col.fromTable || dataSourceTable; const selectedJoinId = col.fromJoinId || ""; // 사용 가능한 테이블 목록 (소스 테이블 + 조인 테이블들) const availableTables = [ { id: "", table: dataSourceTable, label: `${dataSourceTable} (소스)` }, ...additionalJoins .filter(j => j.joinTable) .map(j => ({ id: j.id, table: j.joinTable, label: `${j.joinTable} (조인)` })), ]; useEffect(() => { setLocalLabel(col.label || ""); setLocalWidth(col.width || ""); }, [col.label, col.width]); const handleLabelBlur = () => { if (localLabel !== col.label) { onUpdate({ label: localLabel }); } }; const handleWidthBlur = () => { if (localWidth !== col.width) { onUpdate({ width: localWidth }); } }; return (
{/* 순서 변경 버튼 */}
컬럼 {colIndex + 1} {col.fromJoinId && ( 조인 )}
{/* 테이블 선택 (조인 테이블이 있을 때만 표시) */} {additionalJoins.length > 0 && (
)}
onUpdate({ field: value })} placeholder="필드 선택" />
setLocalLabel(e.target.value)} onBlur={handleLabelBlur} onKeyDown={(e) => e.key === "Enter" && handleLabelBlur()} placeholder="표시명" className="h-6 text-[10px]" />
setLocalWidth(e.target.value)} onBlur={handleWidthBlur} onKeyDown={(e) => e.key === "Enter" && handleWidthBlur()} placeholder="100px" className="h-6 text-[10px]" />
{/* 편집 설정 */}
onUpdate({ editable: checked })} className="scale-[0.6]" />
{/* 편집 가능할 때 저장 설정 */} {col.editable && (
{col.targetConfig?.targetTable && ( onUpdate({ targetConfig: { ...col.targetConfig, targetColumn: value, } as ColumnTargetConfig, }) } /> )}
)}
); }