"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, } 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"; 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{"}"})

); } // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) // 🆕 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 [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 }); }; // === (레거시) 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 || []).map((agg, index) => ( updateAggregation(index, updates)} onRemove={() => removeAggregation(index)} /> ))} {(localConfig.grouping?.aggregations || []).length === 0 && (

집계 설정이 없습니다

)}
)}
{/* === 레이아웃 설정 탭 === */}
{/* 행 추가 버튼들 */}

행 추가