"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 } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { RepeatScreenModalProps, CardRowConfig, CardColumnConfig, ColumnSourceConfig, ColumnTargetConfig, DataSourceConfig, 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}"}

); } // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) function AggregationConfigItem({ agg, index, sourceTable, onUpdate, onRemove, }: { agg: AggregationConfig; index: number; sourceTable: string; onUpdate: (updates: Partial) => void; onRemove: () => void; }) { const [localLabel, setLocalLabel] = useState(agg.label || ""); const [localResultField, setLocalResultField] = useState(agg.resultField || ""); // agg 변경 시 로컬 상태 동기화 useEffect(() => { setLocalLabel(agg.label || ""); setLocalResultField(agg.resultField || ""); }, [agg.label, agg.resultField]); return (
집계 {index + 1}
onUpdate({ sourceField: value })} placeholder="합계할 필드" />
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]" />
); } // 테이블 선택기 (Combobox) - 240px 최적화 function TableSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) { const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { 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(); }, []); const selectedTable = (tables || []).find((t) => t.tableName === value); const displayText = selectedTable ? selectedTable.tableName : "테이블 선택"; 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 = () => { const newAgg: AggregationConfig = { sourceField: "", type: "sum", 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 = { 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 }); }; // 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 }); }; // === (레거시) 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 && (

집계 설정이 없습니다

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

행 추가