"use client"; /** * pop-card-list-v2 설정 패널 (3탭) * * 탭 1: 데이터 — 테이블/컬럼 선택, 조인, 정렬 * 탭 2: 카드 디자인 — 열 수, 시각적 그리드 디자이너, 셀 클릭 시 타입별 상세 인라인 * 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트 */ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Check, ChevronsUpDown, Plus, Minus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; 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 type { PopCardListV2Config, CardGridConfigV2, CardCellDefinitionV2, CardCellType, CardListDataSource, CardColumnJoin, CardSortConfig, V2OverflowConfig, V2CardClickAction, V2CardClickModalConfig, ActionButtonUpdate, TimelineDataSource, StatusValueMapping, TimelineStatusSemantic, SelectModeButtonConfig, ActionButtonDef, ActionButtonShowCondition, ActionButtonClickAction, } from "../types"; import type { ButtonVariant } from "../pop-button"; import { fetchTableList, fetchTableColumns, type TableInfo, type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; // ===== Props ===== interface ConfigPanelProps { config: PopCardListV2Config | undefined; onUpdate: (config: PopCardListV2Config) => void; } // ===== 기본 설정값 ===== const V2_DEFAULT_CONFIG: PopCardListV2Config = { dataSource: { tableName: "" }, cardGrid: { rows: 1, cols: 1, colWidths: ["1fr"], rowHeights: ["32px"], gap: 4, showCellBorder: true, cells: [], }, gridColumns: 3, cardGap: 8, scrollDirection: "vertical", overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, cardClickAction: "none", }; // ===== 탭 정의 ===== type V2ConfigTab = "data" | "design" | "actions"; const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [ { id: "data", label: "데이터" }, { id: "design", label: "카드 디자인" }, { id: "actions", label: "동작" }, ]; // ===== 셀 타입 라벨 ===== const V2_CELL_TYPE_LABELS: Record = { text: { label: "텍스트", group: "기본" }, field: { label: "필드 (라벨+값)", group: "기본" }, image: { label: "이미지", group: "기본" }, badge: { label: "배지", group: "기본" }, button: { label: "버튼", group: "동작" }, "number-input": { label: "숫자 입력", group: "입력" }, "cart-button": { label: "담기 버튼", group: "입력" }, "package-summary": { label: "포장 요약", group: "요약" }, "status-badge": { label: "상태 배지", group: "표시" }, timeline: { label: "타임라인", group: "표시" }, "footer-status": { label: "하단 상태", group: "표시" }, "action-buttons": { label: "액션 버튼", group: "동작" }, }; const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const; // ===== 그리드 유틸 ===== const parseFr = (v: string): number => { const num = parseFloat(v); return isNaN(num) || num <= 0 ? 1 : num; }; const GRID_LIMITS = { cols: { min: 1, max: 6 }, rows: { min: 1, max: 6 }, gap: { min: 0, max: 16 }, minFr: 0.3, } as const; const DEFAULT_ROW_HEIGHT = 32; const MIN_ROW_HEIGHT = 24; const parsePx = (v: string): number => { const num = parseInt(v); return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num; }; const migrateRowHeight = (v: string): string => { if (!v || v.endsWith("fr")) { return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`; } if (v.endsWith("px")) return v; const num = parseInt(v); return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`; }; const shortType = (t: string): string => { const lower = t.toLowerCase(); if (lower.includes("character varying") || lower === "varchar") return "varchar"; if (lower === "text") return "text"; if (lower.includes("timestamp")) return "ts"; if (lower === "integer" || lower === "int4") return "int"; if (lower === "bigint" || lower === "int8") return "bigint"; if (lower === "numeric" || lower === "decimal") return "num"; if (lower === "boolean" || lower === "bool") return "bool"; if (lower === "date") return "date"; if (lower === "jsonb" || lower === "json") return "json"; return t.length > 8 ? t.slice(0, 6) + ".." : t; }; // ===== 메인 컴포넌트 ===== export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) { const [tab, setTab] = useState("data"); const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [selectedColumns, setSelectedColumns] = useState([]); const cfg: PopCardListV2Config = { ...V2_DEFAULT_CONFIG, ...config, dataSource: { ...V2_DEFAULT_CONFIG.dataSource, ...config?.dataSource }, cardGrid: { ...V2_DEFAULT_CONFIG.cardGrid, ...config?.cardGrid }, overflow: { ...V2_DEFAULT_CONFIG.overflow, ...config?.overflow } as V2OverflowConfig, }; const update = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); }; useEffect(() => { fetchTableList() .then(setTables) .catch(() => setTables([])); }, []); useEffect(() => { if (!cfg.dataSource.tableName) { setColumns([]); return; } fetchTableColumns(cfg.dataSource.tableName) .then(setColumns) .catch(() => setColumns([])); }, [cfg.dataSource.tableName]); useEffect(() => { if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { setSelectedColumns(cfg.selectedColumns); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [cfg.dataSource.tableName]); return (
{/* 탭 바 */}
{TAB_LABELS.map((t) => ( ))}
{/* 탭 컨텐츠 */} {tab === "data" && ( { setSelectedColumns([]); update({ dataSource: { ...cfg.dataSource, tableName }, selectedColumns: [], cardGrid: { ...cfg.cardGrid, cells: [] }, }); }} onColumnsChange={(cols) => { setSelectedColumns(cols); update({ selectedColumns: cols }); }} onDataSourceChange={(dataSource) => update({ dataSource })} onSortChange={(sort) => update({ dataSource: { ...cfg.dataSource, sort } }) } /> )} {tab === "design" && ( update({ cardGrid })} onGridColumnsChange={(gridColumns) => update({ gridColumns })} onCardGapChange={(cardGap) => update({ cardGap })} /> )} {tab === "actions" && ( )}
); } // ===== 탭 1: 데이터 ===== function TabData({ cfg, tables, columns, selectedColumns, onTableChange, onColumnsChange, onDataSourceChange, onSortChange, }: { cfg: PopCardListV2Config; tables: TableInfo[]; columns: ColumnInfo[]; selectedColumns: string[]; onTableChange: (tableName: string) => void; onColumnsChange: (cols: string[]) => void; onDataSourceChange: (ds: CardListDataSource) => void; onSortChange: (sort: CardSortConfig[] | undefined) => void; }) { const [tableOpen, setTableOpen] = useState(false); const ds = cfg.dataSource; const selectedDisplay = ds.tableName ? tables.find((t) => t.tableName === ds.tableName)?.displayName || ds.tableName : ""; const toggleColumn = (colName: string) => { if (selectedColumns.includes(colName)) { onColumnsChange(selectedColumns.filter((c) => c !== colName)); } else { onColumnsChange([...selectedColumns, colName]); } }; const sort = ds.sort?.[0]; return (
{/* 테이블 선택 */}
검색 결과가 없습니다 { onTableChange(""); setTableOpen(false); }} className="text-xs" > 선택 안 함 {tables.map((t) => ( { onTableChange(t.tableName); setTableOpen(false); }} className="text-xs" >
{t.displayName || t.tableName} {t.displayName && t.displayName !== t.tableName && ( {t.tableName} )}
))}
{/* 컬럼 선택 */} {ds.tableName && columns.length > 0 && (
{columns.map((col) => ( ))}
)} {/* 조인 설정 (접이식) */} {ds.tableName && ( )} {/* 정렬 */} {ds.tableName && columns.length > 0 && (
{sort?.column && ( )}
)}
); } // ===== 조인 섹션 ===== function JoinSection({ dataSource, tables, mainColumns, onChange, }: { dataSource: CardListDataSource; tables: TableInfo[]; mainColumns: ColumnInfo[]; onChange: (ds: CardListDataSource) => void; }) { const [expanded, setExpanded] = useState((dataSource.joins?.length || 0) > 0); const joins = dataSource.joins || []; const addJoin = () => { const newJoin: CardColumnJoin = { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "", }; onChange({ ...dataSource, joins: [...joins, newJoin] }); setExpanded(true); }; const removeJoin = (index: number) => { onChange({ ...dataSource, joins: joins.filter((_, i) => i !== index) }); }; const updateJoin = (index: number, partial: Partial) => { onChange({ ...dataSource, joins: joins.map((j, i) => (i === index ? { ...j, ...partial } : j)), }); }; return (
{expanded && (

다른 테이블의 데이터를 연결하여 함께 표시 (선택사항)

{joins.map((join, i) => ( updateJoin(i, partial)} onRemove={() => removeJoin(i)} /> ))}
)}
); } // ===== 조인 아이템 ===== function JoinItemV2({ join, index, tables, mainColumns, mainTableName, onUpdate, onRemove, }: { join: CardColumnJoin; index: number; tables: TableInfo[]; mainColumns: ColumnInfo[]; mainTableName: string; onUpdate: (partial: Partial) => void; onRemove: () => void; }) { const [targetColumns, setTargetColumns] = useState([]); const [tableOpen, setTableOpen] = useState(false); useEffect(() => { if (!join.targetTable) { setTargetColumns([]); return; } fetchTableColumns(join.targetTable) .then(setTargetColumns) .catch(() => setTargetColumns([])); }, [join.targetTable]); const autoMatches = mainColumns.filter((mc) => targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type) ); const selectableTables = tables.filter((t) => t.tableName !== mainTableName); const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== ""; const selectedTargetCols = join.selectedTargetColumns || []; const pickableTargetCols = targetColumns.filter((tc) => tc.name !== join.targetColumn); const toggleTargetCol = (colName: string) => { const next = selectedTargetCols.includes(colName) ? selectedTargetCols.filter((c) => c !== colName) : [...selectedTargetCols, colName]; onUpdate({ selectedTargetColumns: next }); }; return (
연결 #{index + 1}
{/* 대상 테이블 */} 없음 {selectableTables.map((t) => ( { onUpdate({ targetTable: t.tableName, sourceColumn: "", targetColumn: "", selectedTargetColumns: [] }); setTableOpen(false); }} className="text-[10px]" > {t.tableName} ))} {/* 자동 매칭 */} {join.targetTable && autoMatches.length > 0 && (
연결 조건 {autoMatches.map((mc) => { const isSelected = join.sourceColumn === mc.name && join.targetColumn === mc.name; return ( ); })}
)} {/* 수동 매칭 */} {join.targetTable && autoMatches.length === 0 && (
=
)} {/* 표시 방식 */} {join.targetTable && (
{(["LEFT", "INNER"] as const).map((jt) => ( ))}
)} {/* 가져올 컬럼 */} {hasJoinCondition && pickableTargetCols.length > 0 && (
가져올 컬럼 ({selectedTargetCols.length}개)
{pickableTargetCols.map((tc) => { const isChecked = selectedTargetCols.includes(tc.name); return ( ); })}
)}
); } // ===== 탭 2: 카드 디자인 ===== function TabCardDesign({ cfg, columns, selectedColumns, tables, onGridChange, onGridColumnsChange, onCardGapChange, }: { cfg: PopCardListV2Config; columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; onGridChange: (g: CardGridConfigV2) => void; onGridColumnsChange: (n: number) => void; onCardGapChange: (n: number) => void; }) { const availableColumns = columns.filter((c) => selectedColumns.includes(c.name)); const joinedColumns = (cfg.dataSource.joins || []).flatMap((j) => (j.selectedTargetColumns || []).map((col) => ({ name: `${j.targetTable}.${col}`, displayName: col, sourceTable: j.targetTable, })) ); const allColumnOptions = [ ...availableColumns.map((c) => ({ value: c.name, label: c.name })), ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), ]; const [selectedCellId, setSelectedCellId] = useState(null); const [mergeMode, setMergeMode] = useState(false); const [mergeCellKeys, setMergeCellKeys] = useState>(new Set()); const widthBarRef = useRef(null); const gridRef = useRef(null); const gridConfigRef = useRef(undefined); const isDraggingRef = useRef(false); const [gridLines, setGridLines] = useState<{ colLines: number[]; rowLines: number[] }>({ colLines: [], rowLines: [] }); // 그리드 정규화 const rawGrid = cfg.cardGrid; const migratedRowHeights = (rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(migrateRowHeight); const safeColWidths = rawGrid.colWidths || []; const normalizedColWidths = safeColWidths.length >= rawGrid.cols ? safeColWidths.slice(0, rawGrid.cols) : [...safeColWidths, ...Array(rawGrid.cols - safeColWidths.length).fill("1fr")]; const normalizedRowHeights = migratedRowHeights.length >= rawGrid.rows ? migratedRowHeights.slice(0, rawGrid.rows) : [...migratedRowHeights, ...Array(rawGrid.rows - migratedRowHeights.length).fill(`${DEFAULT_ROW_HEIGHT}px`)]; const grid: CardGridConfigV2 = { ...rawGrid, colWidths: normalizedColWidths, rowHeights: normalizedRowHeights, }; gridConfigRef.current = grid; const updateGrid = (partial: Partial) => { onGridChange({ ...grid, ...partial }); }; // 점유 맵 const buildOccupationMap = (): Record => { const map: Record = {}; grid.cells.forEach((cell) => { const rs = Number(cell.rowSpan) || 1; const cs = Number(cell.colSpan) || 1; for (let r = cell.row; r < cell.row + rs; r++) { for (let c = cell.col; c < cell.col + cs; c++) { map[`${r}-${c}`] = cell.id; } } }); return map; }; const occupationMap = buildOccupationMap(); const getCellByOrigin = (r: number, c: number) => grid.cells.find((cell) => cell.row === r && cell.col === c); // 셀 CRUD const addCellAt = (row: number, col: number) => { const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row, col, rowSpan: 1, colSpan: 1, type: "text", }; updateGrid({ cells: [...grid.cells, newCell] }); setSelectedCellId(newCell.id); }; const removeCell = (id: string) => { updateGrid({ cells: grid.cells.filter((c) => c.id !== id) }); if (selectedCellId === id) setSelectedCellId(null); }; const updateCell = (id: string, partial: Partial) => { updateGrid({ cells: grid.cells.map((c) => (c.id === id ? { ...c, ...partial } : c)) }); }; // 병합 const toggleMergeMode = () => { if (mergeMode) { setMergeMode(false); setMergeCellKeys(new Set()); } else { setMergeMode(true); setMergeCellKeys(new Set()); setSelectedCellId(null); } }; const toggleMergeCell = (row: number, col: number) => { const key = `${row}-${col}`; if (occupationMap[key]) return; const next = new Set(mergeCellKeys); if (next.has(key)) next.delete(key); else next.add(key); setMergeCellKeys(next); }; const validateMerge = (): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null => { if (mergeCellKeys.size < 2) return null; const positions = Array.from(mergeCellKeys).map((k) => { const [r, c] = k.split("-").map(Number); return { row: r, col: c }; }); const minRow = Math.min(...positions.map((p) => p.row)); const maxRow = Math.max(...positions.map((p) => p.row)); const minCol = Math.min(...positions.map((p) => p.col)); const maxCol = Math.max(...positions.map((p) => p.col)); if (mergeCellKeys.size !== (maxRow - minRow + 1) * (maxCol - minCol + 1)) return null; for (const key of mergeCellKeys) { if (occupationMap[key]) return null; } return { minRow, maxRow, minCol, maxCol }; }; const confirmMerge = () => { const bbox = validateMerge(); if (!bbox) return; const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: bbox.minRow, col: bbox.minCol, rowSpan: bbox.maxRow - bbox.minRow + 1, colSpan: bbox.maxCol - bbox.minCol + 1, type: "text", }; updateGrid({ cells: [...grid.cells, newCell] }); setSelectedCellId(newCell.id); setMergeMode(false); setMergeCellKeys(new Set()); }; // 셀 분할 const splitCellHorizontally = (cell: CardCellDefinitionV2) => { const cs = Number(cell.colSpan) || 1; const rs = Number(cell.rowSpan) || 1; if (cs >= 2) { const leftSpan = Math.ceil(cs / 2); const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: cell.col + leftSpan, rowSpan: rs, colSpan: cs - leftSpan, type: "text" }; const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, colSpan: leftSpan } : c); updateGrid({ cells: [...updatedCells, newCell] }); setSelectedCellId(newCell.id); } else { if (grid.cols >= GRID_LIMITS.cols.max) return; const insertPos = cell.col + 1; const updatedCells = grid.cells.map((c) => { if (c.id === cell.id) return c; const cEnd = c.col + (Number(c.colSpan) || 1) - 1; if (c.col >= insertPos) return { ...c, col: c.col + 1 }; if (cEnd >= insertPos) return { ...c, colSpan: (Number(c.colSpan) || 1) + 1 }; return c; }); const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: insertPos, rowSpan: rs, colSpan: 1, type: "text" }; const colIdx = cell.col - 1; if (colIdx < 0 || colIdx >= grid.colWidths.length) return; const currentFr = parseFr(grid.colWidths[colIdx]); const halfFr = Math.max(GRID_LIMITS.minFr, currentFr / 2); const frStr = `${Math.round(halfFr * 10) / 10}fr`; const newWidths = [...grid.colWidths]; newWidths[colIdx] = frStr; newWidths.splice(colIdx + 1, 0, frStr); updateGrid({ cols: grid.cols + 1, colWidths: newWidths, cells: [...updatedCells, newCell] }); setSelectedCellId(newCell.id); } }; const splitCellVertically = (cell: CardCellDefinitionV2) => { const rs = Number(cell.rowSpan) || 1; const cs = Number(cell.colSpan) || 1; const heights = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); if (rs >= 2) { const topSpan = Math.ceil(rs / 2); const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row + topSpan, col: cell.col, rowSpan: rs - topSpan, colSpan: cs, type: "text" }; const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, rowSpan: topSpan } : c); updateGrid({ cells: [...updatedCells, newCell] }); setSelectedCellId(newCell.id); } else { if (grid.rows >= GRID_LIMITS.rows.max) return; const insertPos = cell.row + 1; const updatedCells = grid.cells.map((c) => { if (c.id === cell.id) return c; const cEnd = c.row + (Number(c.rowSpan) || 1) - 1; if (c.row >= insertPos) return { ...c, row: c.row + 1 }; if (cEnd >= insertPos) return { ...c, rowSpan: (Number(c.rowSpan) || 1) + 1 }; return c; }); const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: insertPos, col: cell.col, rowSpan: 1, colSpan: cs, type: "text" }; const newHeights = [...heights]; newHeights.splice(cell.row - 1 + 1, 0, `${DEFAULT_ROW_HEIGHT}px`); updateGrid({ rows: grid.rows + 1, rowHeights: newHeights, cells: [...updatedCells, newCell] }); setSelectedCellId(newCell.id); } }; // 클릭 핸들러 const handleEmptyCellClick = (row: number, col: number) => { if (mergeMode) toggleMergeCell(row, col); else addCellAt(row, col); }; const handleCellClick = (cell: CardCellDefinitionV2) => { if (mergeMode) return; setSelectedCellId(selectedCellId === cell.id ? null : cell.id); }; // 열 너비 드래그 const handleColDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => { e.preventDefault(); isDraggingRef.current = true; const startX = e.clientX; const bar = widthBarRef.current; if (!bar) return; const barWidth = bar.offsetWidth; if (barWidth === 0) return; const currentGrid = gridConfigRef.current; if (!currentGrid) return; const startFrs = (currentGrid.colWidths || []).map(parseFr); const totalFr = startFrs.reduce((a, b) => a + b, 0); const onMove = (me: MouseEvent) => { const delta = me.clientX - startX; const frDelta = (delta / barWidth) * totalFr; const newFrs = [...startFrs]; newFrs[dividerIndex] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex] + frDelta); newFrs[dividerIndex + 1] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex + 1] - frDelta); onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) }); }; const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [onGridChange]); // 행 높이 드래그 const handleRowDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => { e.preventDefault(); isDraggingRef.current = true; const startY = e.clientY; const currentGrid = gridConfigRef.current; if (!currentGrid) return; const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx); if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return; const onMove = (me: MouseEvent) => { const delta = me.clientY - startY; const newH = [...heights]; newH[dividerIndex] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex] + delta); newH[dividerIndex + 1] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex + 1] - delta); onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) }); }; const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [onGridChange]); // 내부 셀 경계 드래그 useEffect(() => { const gridEl = gridRef.current; if (!gridEl) return; const measure = () => { if (isDraggingRef.current) return; const style = window.getComputedStyle(gridEl); const colSizes = style.gridTemplateColumns.split(" ").map(parseFloat).filter((v) => !isNaN(v)); const rowSizes = style.gridTemplateRows.split(" ").map(parseFloat).filter((v) => !isNaN(v)); const gapSize = parseFloat(style.gap) || 0; const colLines: number[] = []; let x = 0; for (let i = 0; i < colSizes.length - 1; i++) { x += colSizes[i] + gapSize; colLines.push(x - gapSize / 2); } const rowLines: number[] = []; let y = 0; for (let i = 0; i < rowSizes.length - 1; i++) { y += rowSizes[i] + gapSize; rowLines.push(y - gapSize / 2); } setGridLines({ colLines, rowLines }); }; const observer = new ResizeObserver(measure); observer.observe(gridEl); measure(); return () => observer.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]); const handleInternalColDrag = useCallback((e: React.MouseEvent, lineIdx: number) => { e.preventDefault(); e.stopPropagation(); isDraggingRef.current = true; const startX = e.clientX; const gridEl = gridRef.current; if (!gridEl) return; const gridWidth = gridEl.offsetWidth; if (gridWidth === 0) return; const currentGrid = gridConfigRef.current; if (!currentGrid) return; const startFrs = (currentGrid.colWidths || []).map(parseFr); const totalFr = startFrs.reduce((a, b) => a + b, 0); const onMove = (me: MouseEvent) => { const delta = me.clientX - startX; const frDelta = (delta / gridWidth) * totalFr; const newFrs = [...startFrs]; newFrs[lineIdx] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx] + frDelta); newFrs[lineIdx + 1] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx + 1] - frDelta); onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) }); }; const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [onGridChange]); const handleInternalRowDrag = useCallback((e: React.MouseEvent, lineIdx: number) => { e.preventDefault(); e.stopPropagation(); isDraggingRef.current = true; const startY = e.clientY; const currentGrid = gridConfigRef.current; if (!currentGrid) return; const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx); if (lineIdx < 0 || lineIdx + 1 >= heights.length) return; const onMove = (me: MouseEvent) => { const delta = me.clientY - startY; const newH = [...heights]; newH[lineIdx] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx] + delta); newH[lineIdx + 1] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx + 1] - delta); onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) }); }; const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [onGridChange]); // 경계선 가시성 const isColLineVisible = (lineIdx: number): boolean => { const leftCol = lineIdx + 1, rightCol = lineIdx + 2; for (let r = 1; r <= grid.rows; r++) { const left = occupationMap[`${r}-${leftCol}`], right = occupationMap[`${r}-${rightCol}`]; if (left !== right || (!left && !right)) return true; } return false; }; const isRowLineVisible = (lineIdx: number): boolean => { const topRow = lineIdx + 1, bottomRow = lineIdx + 2; for (let c = 1; c <= grid.cols; c++) { const top = occupationMap[`${topRow}-${c}`], bottom = occupationMap[`${bottomRow}-${c}`]; if (top !== bottom || (!top && !bottom)) return true; } return false; }; const selectedCell = selectedCellId ? grid.cells.find((c) => c.id === selectedCellId) : null; useEffect(() => { if (selectedCellId && !grid.cells.find((c) => c.id === selectedCellId)) setSelectedCellId(null); }, [grid.cells, selectedCellId]); const mergeValid = validateMerge(); const gridPositions: { row: number; col: number }[] = []; for (let r = 1; r <= grid.rows; r++) { for (let c = 1; c <= grid.cols; c++) { gridPositions.push({ row: r, col: c }); } } const rowHeightsArr = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); // 바 그룹핑 type BarGroup = { startIdx: number; count: number; totalFr: number }; const colGroups: BarGroup[] = (() => { const groups: BarGroup[] = []; if (grid.colWidths.length === 0) return groups; let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parseFr(grid.colWidths[0]) }; for (let i = 0; i < grid.cols - 1; i++) { if (isColLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parseFr(grid.colWidths[i + 1]) }; } else { cur.count++; cur.totalFr += parseFr(grid.colWidths[i + 1]); } } groups.push(cur); return groups; })(); const rowGroups: BarGroup[] = (() => { const groups: BarGroup[] = []; if (rowHeightsArr.length === 0) return groups; let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parsePx(rowHeightsArr[0]) }; for (let i = 0; i < grid.rows - 1; i++) { if (isRowLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parsePx(rowHeightsArr[i + 1]) }; } else { cur.count++; cur.totalFr += parsePx(rowHeightsArr[i + 1]); } } groups.push(cur); return groups; })(); return (
{/* 카드 배치 */}
열 수 {cfg.gridColumns || 3}
카드 간격 {cfg.cardGap || 8}px
{/* 인라인 툴바 */}
간격 {grid.gap}px
{/* 병합 모드 안내 */} {mergeMode && (
{mergeCellKeys.size > 0 ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` : "빈 셀을 클릭하여 선택"}
)} {/* 열 너비 드래그 바 */}
{colGroups.map((group, gi) => (
{group.count > 1 ? `${Math.round(group.totalFr * 10) / 10}fr` : grid.colWidths[group.startIdx]}
{gi < colGroups.length - 1 && (
handleColDragStart(e, group.startIdx + group.count - 1)} /> )} ))}
{/* 행 높이 바 + 그리드 */}
{rowGroups.map((group, gi) => (
{Math.round(group.totalFr)}
{gi < rowGroups.length - 1 && (
handleRowDragStart(e, group.startIdx + group.count - 1)} /> )} ))}
0 ? grid.colWidths.map((w) => `minmax(30px, ${w})`).join(" ") : "1fr", gridTemplateRows: rowHeightsArr.join(" "), gap: `${Number(grid.gap) || 0}px`, }} > {gridPositions.map(({ row, col }) => { const cellAtOrigin = getCellByOrigin(row, col); const occupiedBy = occupationMap[`${row}-${col}`]; const isMergeSelected = mergeCellKeys.has(`${row}-${col}`); if (occupiedBy && !cellAtOrigin) return null; if (cellAtOrigin) { const isSelected = selectedCellId === cellAtOrigin.id; return (
handleCellClick(cellAtOrigin)} >
{cellAtOrigin.columnName || cellAtOrigin.label || "미지정"} {V2_CELL_TYPE_LABELS[cellAtOrigin.type]?.label || cellAtOrigin.type}
); } return (
handleEmptyCellClick(row, col)} > {isMergeSelected ? : }
); })}
{/* 내부 경계 드래그 오버레이 */}
{gridLines.colLines.map((x, i) => { if (!isColLineVisible(i)) return null; return
handleInternalColDrag(e, i)} />; })} {gridLines.rowLines.map((y, i) => { if (!isRowLineVisible(i)) return null; return
handleInternalRowDrag(e, i)} />; })}

{grid.cols}열 x {grid.rows}행 (최대 {GRID_LIMITS.cols.max}x{GRID_LIMITS.rows.max})

{/* 선택된 셀 설정 패널 */} {selectedCell && !mergeMode && ( updateCell(selectedCell.id, partial)} onRemove={() => removeCell(selectedCell.id)} /> )}
); } // ===== 셀 상세 에디터 (타입별 인라인) ===== function CellDetailEditor({ cell, allCells, allColumnOptions, columns, selectedColumns, tables, dataSource, onUpdate, onRemove, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; dataSource: CardListDataSource; onUpdate: (partial: Partial) => void; onRemove: () => void; }) { const availableTableOptions = useMemo(() => { const opts: { value: string; label: string }[] = []; if (dataSource.tableName) { opts.push({ value: dataSource.tableName, label: `${dataSource.tableName} (메인)` }); } for (const j of dataSource.joins || []) { if (j.targetTable) { opts.push({ value: j.targetTable, label: `${j.targetTable} (조인)` }); } } const added = new Set(opts.map((o) => o.value)); for (const c of allCells) { const pt = c.timelineSource?.processTable; if (pt && !added.has(pt)) { opts.push({ value: pt, label: `${pt} (타임라인)` }); added.add(pt); } } return opts; }, [dataSource, allCells]); return (
셀 (행{cell.row} 열{cell.col} {((Number(cell.colSpan) || 1) > 1 || (Number(cell.rowSpan) || 1) > 1) && `, ${Number(cell.colSpan) || 1}x${Number(cell.rowSpan) || 1}`})
{/* 컬럼 + 타입 */}
{cell.type !== "action-buttons" && ( )}
{/* 라벨 + 위치 */}
onUpdate({ label: e.target.value })} placeholder="라벨 (선택)" className="h-7 flex-1 text-[10px]" />
{/* 크기 + 정렬 */}
{/* 타입별 상세 설정 */} {cell.type === "status-badge" && } {cell.type === "timeline" && } {cell.type === "action-buttons" && } {cell.type === "footer-status" && } {cell.type === "field" && } {cell.type === "number-input" && (
숫자 입력 설정
onUpdate({ inputUnit: e.target.value })} placeholder="단위 (EA)" className="h-7 flex-1 text-[10px]" />
)} {cell.type === "cart-button" && (
담기 버튼 설정
onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" /> onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" />
)}
); } // ===== 상태 배지 매핑 에디터 ===== const SEMANTIC_COLORS: Record = { pending: "#64748b", active: "#3b82f6", done: "#10b981", }; function StatusMappingEditor({ cell, allCells, onUpdate, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; onUpdate: (partial: Partial) => void; }) { const statusMap = cell.statusMap || []; const timelineCell = allCells.find( (c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length, ); const hasTimeline = !!timelineCell; const loadFromTimeline = () => { const src = timelineCell?.timelineSource; if (!src?.statusMappings) return; const partial: Partial = { statusMap: src.statusMappings.map((m) => ({ value: m.dbValue, label: m.label, color: SEMANTIC_COLORS[m.semantic] || "#6b7280", })), }; if (src.statusColumn) { partial.column = src.statusColumn; } onUpdate(partial); }; const addMapping = () => { onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] }); }; const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => { onUpdate({ statusMap: statusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) }); }; const removeMapping = (index: number) => { onUpdate({ statusMap: statusMap.filter((_, i) => i !== index) }); }; return (
상태값-색상 매핑
{hasTimeline && ( )}
{statusMap.map((m, i) => (
updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" /> updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" />
))}
); } // ===== 타임라인 설정 ===== function TimelineConfigEditor({ cell, allColumnOptions, tables, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; tables: TableInfo[]; onUpdate: (partial: Partial) => void; }) { const src = cell.timelineSource || { processTable: "", foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" }; const [processColumns, setProcessColumns] = useState([]); const [tableOpen, setTableOpen] = useState(false); useEffect(() => { if (!src.processTable) { setProcessColumns([]); return; } fetchTableColumns(src.processTable) .then(setProcessColumns) .catch(() => setProcessColumns([])); }, [src.processTable]); const updateSource = (partial: Partial) => { onUpdate({ timelineSource: { ...src, ...partial } }); }; const colOptions = processColumns.map((c) => ({ value: c.name, label: c.name })); return (
하위 데이터 소스 {/* 하위 테이블 선택 */}
테이블을 찾을 수 없습니다. {tables.map((t) => ( { updateSource({ processTable: t.tableName, foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" }); setTableOpen(false); }} className="text-[10px]" > {t.displayName || t.tableName} ))}
{/* 컬럼 매핑 (하위 테이블 선택 후) */} {src.processTable && processColumns.length > 0 && (
연결 FK
순서
표시명
상태
)} {/* 상태 값 매핑 (동적 배열) */} {src.processTable && src.statusColumn && ( updateSource({ statusMappings: mappings })} /> )} {/* 구분선 */}
표시 옵션
최대 표시 수 onUpdate({ visibleCount: parseInt(e.target.value) || 5 })} className="h-7 w-16 text-[10px]" />
onUpdate({ currentHighlight: v })} />
onUpdate({ showDetailModal: v })} /> 전체 목록 모달
); } // ===== 상태 값 매핑 에디터 (동적 배열) ===== const SEMANTIC_OPTIONS: { value: TimelineStatusSemantic; label: string }[] = [ { value: "pending", label: "대기" }, { value: "active", label: "진행" }, { value: "done", label: "완료" }, ]; const DEFAULT_STATUS_MAPPINGS: StatusValueMapping[] = [ { dbValue: "waiting", label: "대기", semantic: "pending" }, { dbValue: "accepted", label: "접수", semantic: "active" }, { dbValue: "in_progress", label: "진행중", semantic: "active" }, { dbValue: "completed", label: "완료", semantic: "done" }, ]; function StatusMappingsEditor({ mappings, onChange, }: { mappings: StatusValueMapping[]; onChange: (mappings: StatusValueMapping[]) => void; }) { const addMapping = () => { onChange([...mappings, { dbValue: "", label: "", semantic: "pending" }]); }; const updateMapping = (index: number, partial: Partial) => { onChange(mappings.map((m, i) => (i === index ? { ...m, ...partial } : m))); }; const removeMapping = (index: number) => { onChange(mappings.filter((_, i) => i !== index)); }; const applyDefaults = () => { onChange([...DEFAULT_STATUS_MAPPINGS]); }; return (
{mappings.length === 0 && ( )}

DB 값, 화면 라벨, 의미(대기/진행/완료)를 매핑합니다.

{mappings.map((m, i) => (
updateMapping(i, { dbValue: e.target.value })} placeholder="DB 값" className="h-6 flex-1 text-[10px]" /> updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
))}
); } // ===== 액션 버튼 에디터 (버튼 중심 구조) ===== function ActionButtonsEditor({ cell, allCells, allColumnOptions, availableTableOptions, onUpdate, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; availableTableOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { const buttons = cell.actionButtons || []; const statusOptions = useMemo(() => { const timelineCell = allCells.find( (c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length, ); return timelineCell?.timelineSource?.statusMappings?.map((m) => ({ value: m.dbValue, label: m.label, })) || []; }, [allCells]); const updateButtons = (newBtns: ActionButtonDef[]) => { onUpdate({ actionButtons: newBtns }); }; const addButton = () => { updateButtons([...buttons, { label: "", variant: "default" as ButtonVariant, showCondition: { type: "always" }, clickAction: { type: "immediate" }, }]); }; const updateBtn = (idx: number, partial: Partial) => { updateButtons(buttons.map((b, i) => (i === idx ? { ...b, ...partial } : b))); }; const removeBtn = (idx: number) => { updateButtons(buttons.filter((_, i) => i !== idx)); }; const updateCondition = (idx: number, partial: Partial) => { const btn = buttons[idx]; updateBtn(idx, { showCondition: { ...(btn.showCondition || { type: "always" }), ...partial } }); }; // 다중 액션 체이닝 지원: clickActions 배열 우선, 없으면 clickAction 단일값 폴백 const getActions = (btn: ActionButtonDef): ActionButtonClickAction[] => { if (btn.clickActions && btn.clickActions.length > 0) return btn.clickActions; return [btn.clickAction]; }; const setActions = (bIdx: number, actions: ActionButtonClickAction[]) => { updateBtn(bIdx, { clickActions: actions, clickAction: actions[0] || { type: "immediate" } }); }; const updateAction = (idx: number, aIdx: number, partial: Partial) => { const actions = [...getActions(buttons[idx])]; actions[aIdx] = { ...actions[aIdx], ...partial }; setActions(idx, actions); }; const addAction = (bIdx: number) => { const actions = [...getActions(buttons[bIdx]), { type: "immediate" as const }]; setActions(bIdx, actions); }; const removeAction = (bIdx: number, aIdx: number) => { const actions = getActions(buttons[bIdx]).filter((_, i) => i !== aIdx); if (actions.length === 0) actions.push({ type: "immediate" as const }); setActions(bIdx, actions); }; const addActionUpdate = (bIdx: number, aIdx: number) => { const actions = getActions(buttons[bIdx]); const a = actions[aIdx]; updateAction(bIdx, aIdx, { updates: [...(a.updates || []), { column: "", value: "", valueType: "static" as const }] }); }; const updateActionUpdate = (bIdx: number, aIdx: number, uIdx: number, partial: Partial) => { const a = getActions(buttons[bIdx])[aIdx]; updateAction(bIdx, aIdx, { updates: (a.updates || []).map((u, i) => (i === uIdx ? { ...u, ...partial } : u)) }); }; const removeActionUpdate = (bIdx: number, aIdx: number, uIdx: number) => { const a = getActions(buttons[bIdx])[aIdx]; updateAction(bIdx, aIdx, { updates: (a.updates || []).filter((_, i) => i !== uIdx) }); }; const addSelectModeBtn = (bIdx: number, aIdx: number) => { const a = getActions(buttons[bIdx])[aIdx]; const smBtns = a.selectModeButtons || []; updateAction(bIdx, aIdx, { selectModeButtons: [...smBtns, { label: "", variant: "outline" as ButtonVariant, clickMode: "cancel-select" as const }] }); }; const updateSelectModeBtn = (bIdx: number, aIdx: number, smIdx: number, partial: Partial) => { const a = getActions(buttons[bIdx])[aIdx]; const smBtns = (a.selectModeButtons || []).map((s, i) => (i === smIdx ? { ...s, ...partial } : s)); updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); }; const removeSelectModeBtn = (bIdx: number, aIdx: number, smIdx: number) => { const a = getActions(buttons[bIdx])[aIdx]; updateAction(bIdx, aIdx, { selectModeButtons: (a.selectModeButtons || []).filter((_, i) => i !== smIdx) }); }; const addSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number) => { const a = getActions(buttons[bIdx])[aIdx]; const smBtns = [...(a.selectModeButtons || [])]; smBtns[smIdx] = { ...smBtns[smIdx], updates: [...(smBtns[smIdx].updates || []), { column: "", value: "", valueType: "static" as const }] }; updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); }; const updateSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number, uIdx: number, partial: Partial) => { const a = getActions(buttons[bIdx])[aIdx]; const smBtns = [...(a.selectModeButtons || [])]; smBtns[smIdx] = { ...smBtns[smIdx], updates: (smBtns[smIdx].updates || []).map((u, i) => (i === uIdx ? { ...u, ...partial } : u)) }; updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); }; const removeSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number, uIdx: number) => { const a = getActions(buttons[bIdx])[aIdx]; const smBtns = [...(a.selectModeButtons || [])]; smBtns[smIdx] = { ...smBtns[smIdx], updates: (smBtns[smIdx].updates || []).filter((_, i) => i !== uIdx) }; updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); }; const storageKey = `action-btn-editor-${cell.row}-${cell.col}`; const [expandedBtns, setExpandedBtns] = useState>(() => { try { const saved = sessionStorage.getItem(`${storageKey}-btns`); return saved ? new Set(JSON.parse(saved) as number[]) : new Set(); } catch { return new Set(); } }); const [expandedSections, setExpandedSections] = useState>(() => { try { const saved = sessionStorage.getItem(`${storageKey}-secs`); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); useEffect(() => { try { sessionStorage.setItem(`${storageKey}-btns`, JSON.stringify([...expandedBtns])); } catch {} }, [expandedBtns, storageKey]); useEffect(() => { try { sessionStorage.setItem(`${storageKey}-secs`, JSON.stringify(expandedSections)); } catch {} }, [expandedSections, storageKey]); const toggleBtn = (idx: number) => { setExpandedBtns((prev) => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }; const toggleSection = (key: string) => { setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); }; const isSectionOpen = (key: string) => expandedSections[key] !== false; const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" }; const getCondSummary = (btn: ActionButtonDef) => { const c = btn.showCondition; if (!c || c.type === "always") return "항상"; if (c.type === "timeline-status") { const opt = statusOptions.find((o) => o.value === c.value); return opt ? opt.label : (c.value || "미설정"); } if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`; return "항상"; }; const addButtonAndExpand = () => { addButton(); setExpandedBtns((prev) => new Set([...prev, buttons.length])); }; return (
버튼 규칙
{buttons.length === 0 && (

버튼 규칙을 추가하세요. 상태별로 다른 버튼을 설정할 수 있습니다.

)} {buttons.map((btn, bi) => { const condType = btn.showCondition?.type || "always"; const actions = getActions(btn); const isExpanded = expandedBtns.has(bi); const actionSummary = actions.map((a) => ACTION_TYPE_LABELS[a.type] || a.type).join(" -> "); return (
{/* 접기/펼치기 헤더 */}
toggleBtn(bi)} > {isExpanded ? : } #{bi + 1} {isExpanded ? ( <> updateBtn(bi, { label: e.target.value })} onClick={(e) => e.stopPropagation()} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> ) : ( {btn.label || "(미입력)"} | {getCondSummary(btn)} | {actionSummary} )}
{/* 펼쳐진 상세 */} {isExpanded && (
{/* === 조건 섹션 === */}
toggleSection(`${bi}-cond`)} > {isSectionOpen(`${bi}-cond`) ? : } 조건 {!isSectionOpen(`${bi}-cond`) && ( {getCondSummary(btn)} )}
{isSectionOpen(`${bi}-cond`) && (
활성화 {condType === "timeline-status" && ( )} {condType === "column-value" && ( <> updateCondition(bi, { value: e.target.value })} placeholder="값" className="h-6 w-20 text-[10px]" /> )}
{condType !== "always" && (
그 외 {(btn.showCondition?.unmatchBehavior || "hidden") === "disabled" ? "보이지만 클릭 불가" : "버튼 안 보임"}
)}
)} {/* === 실행 섹션 (다중 액션) === */}
toggleSection(`${bi}-action`)} > {isSectionOpen(`${bi}-action`) ? : } 실행 ({actions.length}) {!isSectionOpen(`${bi}-action`) && ( {actionSummary} )}
{isSectionOpen(`${bi}-action`) && (
{actions.map((action, ai) => { const aType = action.type; return (
#{ai + 1} {actions.length > 1 && ( )}
{aType === "immediate" && ( addActionUpdate(bi, ai)} onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} onUpdateAction={(p) => updateAction(bi, ai, p)} /> )} {aType === "select-mode" && (
로직 순서
{(action.selectModeButtons || []).map((smBtn, si) => (
updateSelectModeBtn(bi, ai, si, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
동작
{smBtn.clickMode === "status-change" && ( addSmBtnUpdate(bi, ai, si)} onUpdateUpdate={(ui, p) => updateSmBtnUpdate(bi, ai, si, ui, p)} onRemoveUpdate={(ui) => removeSmBtnUpdate(bi, ai, si, ui)} onUpdateAction={(p) => updateSelectModeBtn(bi, ai, si, { targetTable: p.targetTable ?? smBtn.targetTable, confirmMessage: p.confirmMessage ?? smBtn.confirmMessage })} /> )} {smBtn.clickMode === "modal-open" && (
POP 화면 updateSelectModeBtn(bi, ai, si, { modalScreenId: e.target.value })} placeholder="화면 ID (예: 4481)" className="h-6 flex-1 text-[10px]" />
)}
))} {(!action.selectModeButtons || action.selectModeButtons.length === 0) && (

로직 순서를 추가하세요.

)}
)} {aType === "modal-open" && (
POP 화면 updateAction(bi, ai, { modalScreenId: e.target.value })} placeholder="화면 ID (예: 4481)" className="h-6 flex-1 text-[10px]" />
)}
); })}
)}
)}
); })}
); } const SYSTEM_COLUMNS = new Set([ "id", "company_code", "created_date", "updated_date", "writer", ]); function ImmediateActionEditor({ action, allColumnOptions, availableTableOptions, onAddUpdate, onUpdateUpdate, onRemoveUpdate, onUpdateAction, }: { action: ActionButtonClickAction; allColumnOptions: { value: string; label: string }[]; availableTableOptions: { value: string; label: string }[]; onAddUpdate: () => void; onUpdateUpdate: (uIdx: number, partial: Partial) => void; onRemoveUpdate: (uIdx: number) => void; onUpdateAction: (partial: Partial) => void; }) { const isExternalTable = action.targetTable && !availableTableOptions.some((t) => t.value === action.targetTable); const [dbSelectMode, setDbSelectMode] = useState(!!isExternalTable); const [allTables, setAllTables] = useState([]); const [tableColumnGroups, setTableColumnGroups] = useState< { table: string; label: string; business: { value: string; label: string }[]; system: { value: string; label: string }[] }[] >([]); // 외부 DB 모드 시 전체 테이블 로드 useEffect(() => { if (dbSelectMode && allTables.length === 0) { fetchTableList().then(setAllTables).catch(() => setAllTables([])); } }, [dbSelectMode, allTables.length]); // 선택된 테이블 컬럼 로드 (카드 소스 + 외부 공통) const effectiveTableOptions = useMemo(() => { if (dbSelectMode && action.targetTable) { const existing = availableTableOptions.find((t) => t.value === action.targetTable); if (!existing) return [...availableTableOptions, { value: action.targetTable, label: `${action.targetTable} (외부)` }]; } return availableTableOptions; }, [availableTableOptions, dbSelectMode, action.targetTable]); useEffect(() => { let cancelled = false; const loadAll = async () => { const groups: typeof tableColumnGroups = []; for (const t of effectiveTableOptions) { try { const cols = await fetchTableColumns(t.value); const mapped = cols.map((c) => ({ value: c.name, label: c.name })); groups.push({ table: t.value, label: t.label, business: mapped.filter((c) => !SYSTEM_COLUMNS.has(c.value)), system: mapped.filter((c) => SYSTEM_COLUMNS.has(c.value)), }); } catch { groups.push({ table: t.value, label: t.label, business: [], system: [] }); } } if (!cancelled) setTableColumnGroups(groups); }; if (effectiveTableOptions.length > 0) loadAll(); else setTableColumnGroups([]); return () => { cancelled = true; }; }, [effectiveTableOptions]); const selectedGroup = tableColumnGroups.find((g) => g.table === action.targetTable); const businessCols = selectedGroup?.business || []; const systemCols = selectedGroup?.system || []; const tableName = action.targetTable?.trim() || ""; // 메인 테이블 컬럼 (조인키 소스 컬럼 선택 용도) const mainTableGroup = tableColumnGroups.find((g) => availableTableOptions[0]?.value === g.table); const mainCols = mainTableGroup ? [...mainTableGroup.business, ...mainTableGroup.system] : []; return (
{/* 대상 테이블 */}
대상 테이블 {!dbSelectMode ? ( ) : (
onUpdateAction({ targetTable: v })} />
)}
{/* 외부 DB 선택 시 조인키 설정 */} {dbSelectMode && action.targetTable && ( <>
기준 컬럼
매칭 컬럼

메인.기준컬럼 = 외부.매칭컬럼 으로 연결하여 업데이트

)}
확인 메시지 onUpdateAction({ confirmMessage: e.target.value })} placeholder="처리하시겠습니까?" className="h-6 flex-1 text-[10px]" />
변경할 컬럼{tableName ? ` (${tableName})` : ""}
{(action.updates || []).map((u, ui) => (
{(u.valueType === "static" || u.valueType === "columnRef") && ( onUpdateUpdate(ui, { value: e.target.value })} placeholder={u.valueType === "static" ? "값" : "컬럼명"} className="h-6 flex-1 text-[10px]" /> )}
))} {(!action.updates || action.updates.length === 0) && (

변경 항목을 추가하면 클릭 시 DB가 변경됩니다.

)}
); } // ===== DB 테이블 검색 Combobox ===== function DbTableCombobox({ value, tables, onSelect, }: { value: string; tables: TableInfo[]; onSelect: (tableName: string) => void; }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const filtered = useMemo(() => { if (!search) return tables; const q = search.toLowerCase(); return tables.filter( (t) => t.tableName.toLowerCase().includes(q) || (t.tableComment || "").toLowerCase().includes(q), ); }, [tables, search]); const selectedLabel = useMemo(() => { if (!value) return "DB 테이블 검색..."; const found = tables.find((t) => t.tableName === value); return found ? `${found.tableName}${found.tableComment ? ` (${found.tableComment})` : ""}` : value; }, [value, tables]); return ( 검색 결과가 없습니다. {filtered.map((t) => ( { onSelect(t.tableName); setOpen(false); setSearch(""); }} className="text-[10px]" > {t.tableName} {t.tableComment && ( ({t.tableComment}) )} ))} ); } // ===== 하단 상태 에디터 ===== function FooterStatusEditor({ cell, allColumnOptions, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { const footerStatusMap = cell.footerStatusMap || []; const addMapping = () => { onUpdate({ footerStatusMap: [...footerStatusMap, { value: "", label: "", color: "#6b7280" }] }); }; const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => { onUpdate({ footerStatusMap: footerStatusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) }); }; const removeMapping = (index: number) => { onUpdate({ footerStatusMap: footerStatusMap.filter((_, i) => i !== index) }); }; return (
하단 상태 설정
onUpdate({ footerLabel: e.target.value })} placeholder="라벨 (예: 검사의뢰)" className="h-7 flex-1 text-[10px]" />
onUpdate({ showTopBorder: v })} />
상태값-색상 매핑
{footerStatusMap.map((m, i) => (
updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" /> updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" />
))}
); } // ===== 필드 설정 에디터 ===== function FieldConfigEditor({ cell, allColumnOptions, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { const valueType = cell.valueType || "column"; return (
필드 설정
onUpdate({ unit: e.target.value })} placeholder="단위" className="h-7 w-16 text-[10px]" />
{valueType === "formula" && (
{cell.formulaRightType === "column" && ( )}
)}
); } // ===== 탭 3: 동작 ===== function TabActions({ cfg, onUpdate, }: { cfg: PopCardListV2Config; onUpdate: (partial: Partial) => void; }) { const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; return (
{/* 카드 선택 시 */}
{(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => ( ))}
{clickAction === "modal-open" && (
POP 화면 ID onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} placeholder="화면 ID (예: 4481)" className="h-7 flex-1 text-[10px]" />
모달 제목 onUpdate({ cardClickModalConfig: { ...modalConfig, modalTitle: e.target.value } })} placeholder="비우면 '상세 작업' 표시" className="h-7 flex-1 text-[10px]" />
조건
{modalConfig.condition?.type === "timeline-status" && (
상태 값 onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} placeholder="예: in_progress" className="h-7 flex-1 text-[10px]" />
)} {modalConfig.condition?.type === "column-value" && ( <>
컬럼 onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, column: e.target.value } } })} placeholder="컬럼명" className="h-7 flex-1 text-[10px]" />
onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} placeholder="값" className="h-7 flex-1 text-[10px]" />
)}
)}
{/* 필터 전 비표시 */}
onUpdate({ hideUntilFiltered: checked })} />
{cfg.hideUntilFiltered && (

연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다.

)} {/* 스크롤 방향 */}
{(["vertical", "horizontal"] as const).map((dir) => ( ))}
{/* 오버플로우 */}
{(["loadMore", "pagination"] as const).map((mode) => ( ))}
onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })} className="mt-0.5 h-7 text-[10px]" />
{overflow.mode === "loadMore" && (
onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })} className="mt-0.5 h-7 text-[10px]" />
)} {overflow.mode === "pagination" && (
onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })} className="mt-0.5 h-7 text-[10px]" />
)}
{/* 장바구니 */}
{ if (checked) { onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } }); } else { onUpdate({ cartAction: undefined }); } }} />
); }