ERP-node/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx

2964 lines
128 KiB
TypeScript

"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<CardCellType, { label: string; group: string }> = {
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<V2ConfigTab>("data");
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
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<PopCardListV2Config>) => {
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 (
<div className="flex flex-col gap-3">
{/* 탭 바 */}
<div className="flex gap-1 rounded-md border bg-muted/30 p-0.5">
{TAB_LABELS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"flex-1 rounded-sm py-1 text-center text-[10px] font-medium transition-colors",
tab === t.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{t.label}
</button>
))}
</div>
{/* 탭 컨텐츠 */}
{tab === "data" && (
<TabData
cfg={cfg}
tables={tables}
columns={columns}
selectedColumns={selectedColumns}
onTableChange={(tableName) => {
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" && (
<TabCardDesign
cfg={cfg}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
onGridChange={(cardGrid) => update({ cardGrid })}
onGridColumnsChange={(gridColumns) => update({ gridColumns })}
onCardGapChange={(cardGap) => update({ cardGap })}
/>
)}
{tab === "actions" && (
<TabActions
cfg={cfg}
onUpdate={update}
/>
)}
</div>
);
}
// ===== 탭 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 (
<div className="space-y-3">
{/* 테이블 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
className="mt-1 h-8 w-full justify-between text-xs font-normal"
>
{ds.tableName ? selectedDisplay : "테이블 검색 / 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블명 검색" className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => { onTableChange(""); setTableOpen(false); }}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !ds.tableName ? "opacity-100" : "opacity-0")} />
</CommandItem>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.tableName} ${t.displayName || ""}`}
onSelect={() => { onTableChange(t.tableName); setTableOpen(false); }}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", ds.tableName === t.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span>{t.displayName || t.tableName}</span>
{t.displayName && t.displayName !== t.tableName && (
<span className="text-[10px] text-muted-foreground">{t.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 선택 */}
{ds.tableName && columns.length > 0 && (
<div>
<Label className="text-xs">
({selectedColumns.length} )
</Label>
<div className="mt-1 max-h-[160px] space-y-0.5 overflow-auto rounded border p-1">
{columns.map((col) => (
<label
key={col.name}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50"
>
<input
type="checkbox"
checked={selectedColumns.includes(col.name)}
onChange={() => toggleColumn(col.name)}
className="h-3 w-3"
/>
<span className="text-xs">{col.name}</span>
<span className="ml-auto text-[10px] text-muted-foreground">
{shortType(col.type)}
</span>
</label>
))}
</div>
</div>
)}
{/* 조인 설정 (접이식) */}
{ds.tableName && (
<JoinSection
dataSource={ds}
tables={tables}
mainColumns={columns}
onChange={onDataSourceChange}
/>
)}
{/* 정렬 */}
{ds.tableName && columns.length > 0 && (
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
<Select
value={sort?.column || "__none__"}
onValueChange={(v) =>
onSortChange(v && v !== "__none__" ? [{ column: v, direction: sort?.direction || "desc" }] : undefined)
}
>
<SelectTrigger className="h-7 flex-1 text-[10px]">
<SelectValue placeholder="정렬 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{columns.map((c) => (
<SelectItem key={c.name} value={c.name} className="text-[10px]">{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{sort?.column && (
<Select
value={sort.direction}
onValueChange={(v) =>
onSortChange([{ column: sort.column, direction: v as "asc" | "desc" }])
}
>
<SelectTrigger className="h-7 w-20 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc" className="text-[10px]"></SelectItem>
<SelectItem value="desc" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
)}
</div>
);
}
// ===== 조인 섹션 =====
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<CardColumnJoin>) => {
onChange({
...dataSource,
joins: joins.map((j, i) => (i === index ? { ...j, ...partial } : j)),
});
};
return (
<div>
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-1 text-xs font-medium"
>
<span className={cn("transition-transform", expanded && "rotate-90")}>
{">"}
</span>
({joins.length})
</button>
{expanded && (
<div className="mt-2 space-y-2">
<p className="text-[10px] text-muted-foreground">
()
</p>
{joins.map((join, i) => (
<JoinItemV2
key={i}
join={join}
index={i}
tables={tables}
mainColumns={mainColumns}
mainTableName={dataSource.tableName}
onUpdate={(partial) => updateJoin(i, partial)}
onRemove={() => removeJoin(i)}
/>
))}
<Button variant="outline" size="sm" onClick={addJoin} className="h-7 w-full text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
)}
</div>
);
}
// ===== 조인 아이템 =====
function JoinItemV2({
join,
index,
tables,
mainColumns,
mainTableName,
onUpdate,
onRemove,
}: {
join: CardColumnJoin;
index: number;
tables: TableInfo[];
mainColumns: ColumnInfo[];
mainTableName: string;
onUpdate: (partial: Partial<CardColumnJoin>) => void;
onRemove: () => void;
}) {
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
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 (
<div className="space-y-2 rounded border p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> #{index + 1}</span>
<Button variant="ghost" size="sm" onClick={onRemove} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
{/* 대상 테이블 */}
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-[10px]">
{join.targetTable || "테이블 선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-[10px]" />
<CommandList>
<CommandEmpty className="py-2 text-center text-[10px]"></CommandEmpty>
<CommandGroup>
{selectableTables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.tableName} ${t.displayName || ""}`}
onSelect={() => {
onUpdate({ targetTable: t.tableName, sourceColumn: "", targetColumn: "", selectedTargetColumns: [] });
setTableOpen(false);
}}
className="text-[10px]"
>
<Check className={cn("mr-1 h-3 w-3", join.targetTable === t.tableName ? "opacity-100" : "opacity-0")} />
{t.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 자동 매칭 */}
{join.targetTable && autoMatches.length > 0 && (
<div className="space-y-0.5">
<span className="text-[9px] text-muted-foreground"> </span>
{autoMatches.map((mc) => {
const isSelected = join.sourceColumn === mc.name && join.targetColumn === mc.name;
return (
<button
key={mc.name}
type="button"
onClick={() => {
if (isSelected) onUpdate({ sourceColumn: "", targetColumn: "" });
else onUpdate({ sourceColumn: mc.name, targetColumn: mc.name });
}}
className={cn(
"flex w-full items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors",
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted"
)}
>
<div className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
isSelected ? "border-primary bg-primary" : "border-muted-foreground/30"
)}>
{isSelected && <Check className="h-2.5 w-2.5 text-primary-foreground" />}
</div>
<span className="flex-1 truncate">{mc.name}</span>
<span className="text-[8px] text-muted-foreground">{shortType(mc.type)}</span>
</button>
);
})}
</div>
)}
{/* 수동 매칭 */}
{join.targetTable && autoMatches.length === 0 && (
<div className="flex items-center gap-1">
<Select value={join.sourceColumn || ""} onValueChange={(v) => onUpdate({ sourceColumn: v, targetColumn: "" })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="메인" /></SelectTrigger>
<SelectContent>
{mainColumns.map((mc) => (
<SelectItem key={mc.name} value={mc.name} className="text-[10px]">{mc.name}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">=</span>
<Select value={join.targetColumn || ""} onValueChange={(v) => onUpdate({ targetColumn: v })} disabled={!join.sourceColumn}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="대상" /></SelectTrigger>
<SelectContent>
{targetColumns.map((tc) => (
<SelectItem key={tc.name} value={tc.name} className="text-[10px]">{tc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 표시 방식 */}
{join.targetTable && (
<div className="flex gap-1">
{(["LEFT", "INNER"] as const).map((jt) => (
<button
key={jt}
type="button"
onClick={() => onUpdate({ joinType: jt })}
className={cn(
"flex-1 rounded border px-2 py-1 text-[10px] transition-colors",
join.joinType === jt ? "border-primary bg-primary/10 text-primary" : "border-border hover:bg-muted"
)}
>
{jt === "LEFT" ? "빈칸 허용" : "일치만"}
</button>
))}
</div>
)}
{/* 가져올 컬럼 */}
{hasJoinCondition && pickableTargetCols.length > 0 && (
<div>
<span className="text-[9px] text-muted-foreground"> ({selectedTargetCols.length})</span>
<div className="mt-1 max-h-[100px] space-y-0.5 overflow-y-auto">
{pickableTargetCols.map((tc) => {
const isChecked = selectedTargetCols.includes(tc.name);
return (
<button
key={tc.name}
type="button"
onClick={() => toggleTargetCol(tc.name)}
className={cn(
"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-[10px] transition-colors",
isChecked ? "bg-primary/10 text-primary" : "hover:bg-muted"
)}
>
<div className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
isChecked ? "border-primary bg-primary" : "border-muted-foreground/30"
)}>
{isChecked && <Check className="h-2.5 w-2.5 text-primary-foreground" />}
</div>
<span className="flex-1 truncate">{tc.name}</span>
<span className="text-[8px] text-muted-foreground">{shortType(tc.type)}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}
// ===== 탭 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<string | null>(null);
const [mergeMode, setMergeMode] = useState(false);
const [mergeCellKeys, setMergeCellKeys] = useState<Set<string>>(new Set());
const widthBarRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const gridConfigRef = useRef<CardGridConfigV2 | undefined>(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<CardGridConfigV2>) => {
onGridChange({ ...grid, ...partial });
};
// 점유 맵
const buildOccupationMap = (): Record<string, string> => {
const map: Record<string, string> = {};
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<CardCellDefinitionV2>) => {
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 (
<div className="space-y-2">
{/* 카드 배치 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="text-[9px] text-muted-foreground"> </span>
<Button variant="ghost" size="sm" onClick={() => onGridColumnsChange(Math.max(1, (cfg.gridColumns || 3) - 1))} className="h-5 w-5 p-0"><Minus className="h-2.5 w-2.5" /></Button>
<span className="w-4 text-center text-[10px]">{cfg.gridColumns || 3}</span>
<Button variant="ghost" size="sm" onClick={() => onGridColumnsChange(Math.min(6, (cfg.gridColumns || 3) + 1))} className="h-5 w-5 p-0"><Plus className="h-2.5 w-2.5" /></Button>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-muted-foreground"> </span>
<Button variant="ghost" size="sm" onClick={() => onCardGapChange(Math.max(0, (cfg.cardGap || 8) - 4))} className="h-5 w-5 p-0"><Minus className="h-2.5 w-2.5" /></Button>
<span className="w-6 text-center text-[9px]">{cfg.cardGap || 8}px</span>
<Button variant="ghost" size="sm" onClick={() => onCardGapChange(Math.min(24, (cfg.cardGap || 8) + 4))} className="h-5 w-5 p-0"><Plus className="h-2.5 w-2.5" /></Button>
</div>
</div>
{/* 인라인 툴바 */}
<div className="flex flex-wrap items-center gap-1.5 rounded border bg-muted/30 px-2 py-1">
<button
onClick={() => updateGrid({ showCellBorder: !grid.showCellBorder })}
className={cn("h-5 rounded border px-1.5 text-[9px] transition-colors", grid.showCellBorder ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-muted-foreground")}
></button>
<div className="flex items-center gap-0.5">
<span className="text-[9px] text-muted-foreground"></span>
<Button variant="ghost" size="sm" onClick={() => updateGrid({ gap: Math.max(GRID_LIMITS.gap.min, grid.gap - 2) })} disabled={grid.gap <= GRID_LIMITS.gap.min} className="h-5 w-5 p-0"><Minus className="h-2.5 w-2.5" /></Button>
<span className="w-6 text-center text-[9px]">{grid.gap}px</span>
<Button variant="ghost" size="sm" onClick={() => updateGrid({ gap: Math.min(GRID_LIMITS.gap.max, grid.gap + 2) })} disabled={grid.gap >= GRID_LIMITS.gap.max} className="h-5 w-5 p-0"><Plus className="h-2.5 w-2.5" /></Button>
</div>
<button onClick={toggleMergeMode} className={cn("h-5 rounded border px-1.5 text-[9px] transition-colors", mergeMode ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-muted-foreground")}></button>
<div className="mx-0.5 h-3 w-px bg-border" />
<Button variant="outline" size="sm" onClick={() => { if (selectedCell) splitCellHorizontally(selectedCell); }} disabled={!selectedCell || (grid.cols >= GRID_LIMITS.cols.max && (Number(selectedCell?.colSpan) || 1) <= 1)} className="h-5 px-1.5 text-[9px]"> </Button>
<Button variant="outline" size="sm" onClick={() => { if (selectedCell) splitCellVertically(selectedCell); }} disabled={!selectedCell || (grid.rows >= GRID_LIMITS.rows.max && (Number(selectedCell?.rowSpan) || 1) <= 1)} className="h-5 px-1.5 text-[9px]"> </Button>
</div>
{/* 병합 모드 안내 */}
{mergeMode && (
<div className="flex items-center gap-1 rounded border border-primary/50 bg-primary/5 px-2 py-1">
<span className="flex-1 text-[9px] text-primary">
{mergeCellKeys.size > 0 ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` : "빈 셀을 클릭하여 선택"}
</span>
<Button variant="default" size="sm" onClick={confirmMerge} disabled={!mergeValid} className="h-5 px-2 text-[9px]"></Button>
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeCellKeys(new Set()); }} className="h-5 px-1.5 text-[9px]"></Button>
</div>
)}
{/* 열 너비 드래그 바 */}
<div className="flex items-center gap-1">
<div className="w-4 shrink-0" />
<div ref={widthBarRef} className="flex h-5 flex-1 select-none overflow-hidden rounded border">
{colGroups.map((group, gi) => (
<Fragment key={gi}>
<div className="flex items-center justify-center bg-muted/30 text-[7px] text-muted-foreground" style={{ flex: group.totalFr }}>
{group.count > 1 ? `${Math.round(group.totalFr * 10) / 10}fr` : grid.colWidths[group.startIdx]}
</div>
{gi < colGroups.length - 1 && (
<div className="w-1 shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary" onMouseDown={(e) => handleColDragStart(e, group.startIdx + group.count - 1)} />
)}
</Fragment>
))}
</div>
</div>
{/* 행 높이 바 + 그리드 */}
<div className="flex gap-1">
<div className="flex w-4 shrink-0 select-none flex-col overflow-hidden rounded border">
{rowGroups.map((group, gi) => (
<Fragment key={gi}>
<div className="flex items-center justify-center bg-muted/30 text-[6px] text-muted-foreground" style={{ flex: group.totalFr }}>{Math.round(group.totalFr)}</div>
{gi < rowGroups.length - 1 && (
<div className="h-1 shrink-0 cursor-row-resize bg-border transition-colors hover:bg-primary" onMouseDown={(e) => handleRowDragStart(e, group.startIdx + group.count - 1)} />
)}
</Fragment>
))}
</div>
<div className="relative flex-1">
<div
ref={gridRef}
className="rounded border bg-muted/10 p-0.5"
style={{
display: "grid",
gridTemplateColumns: grid.colWidths.length > 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 (
<div
key={`${row}-${col}`}
className={cn("flex cursor-pointer items-center justify-center rounded p-0.5 transition-all", isSelected ? "bg-primary/20 ring-2 ring-primary" : "bg-primary/10 hover:bg-primary/15")}
style={{
gridColumn: `${col} / span ${Number(cellAtOrigin.colSpan) || 1}`,
gridRow: `${row} / span ${Number(cellAtOrigin.rowSpan) || 1}`,
border: grid.showCellBorder ? "1px solid hsl(var(--border))" : "none",
}}
onClick={() => handleCellClick(cellAtOrigin)}
>
<div className="flex flex-col items-center gap-0.5 overflow-hidden text-center">
<span className="truncate text-[8px] font-medium text-primary">{cellAtOrigin.columnName || cellAtOrigin.label || "미지정"}</span>
<span className="text-[6px] text-muted-foreground">{V2_CELL_TYPE_LABELS[cellAtOrigin.type]?.label || cellAtOrigin.type}</span>
</div>
</div>
);
}
return (
<div
key={`${row}-${col}`}
className={cn(
"flex cursor-pointer items-center justify-center rounded border border-dashed transition-colors",
isMergeSelected ? "border-primary bg-primary/20 text-primary" : mergeMode ? "border-primary/40 text-primary/40 hover:border-primary hover:bg-primary/10" : "border-muted-foreground/30 text-muted-foreground/40 hover:border-primary/50 hover:bg-primary/5"
)}
style={{ gridColumn: `${col} / span 1`, gridRow: `${row} / span 1` }}
onClick={() => handleEmptyCellClick(row, col)}
>
{isMergeSelected ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</div>
);
})}
</div>
{/* 내부 경계 드래그 오버레이 */}
<div className="pointer-events-none absolute inset-0" style={{ padding: "2px" }}>
{gridLines.colLines.map((x, i) => {
if (!isColLineVisible(i)) return null;
return <div key={`ch-${i}`} className="pointer-events-auto absolute top-0 bottom-0 z-10 cursor-col-resize transition-colors hover:bg-primary/30" style={{ left: `${x - 3}px`, width: "6px" }} onMouseDown={(e) => handleInternalColDrag(e, i)} />;
})}
{gridLines.rowLines.map((y, i) => {
if (!isRowLineVisible(i)) return null;
return <div key={`rh-${i}`} className="pointer-events-auto absolute left-0 right-0 z-10 cursor-row-resize transition-colors hover:bg-primary/30" style={{ top: `${y - 3}px`, height: "6px" }} onMouseDown={(e) => handleInternalRowDrag(e, i)} />;
})}
</div>
</div>
</div>
<p className="text-[8px] text-muted-foreground">
{grid.cols} x {grid.rows} ( {GRID_LIMITS.cols.max}x{GRID_LIMITS.rows.max})
</p>
{/* 선택된 셀 설정 패널 */}
{selectedCell && !mergeMode && (
<CellDetailEditor
cell={selectedCell}
allCells={grid.cells}
allColumnOptions={allColumnOptions}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
dataSource={cfg.dataSource}
onUpdate={(partial) => updateCell(selectedCell.id, partial)}
onRemove={() => removeCell(selectedCell.id)}
/>
)}
</div>
);
}
// ===== 셀 상세 에디터 (타입별 인라인) =====
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<CardCellDefinitionV2>) => 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 (
<div className="space-y-2 rounded border bg-muted/20 p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium">
({cell.row} {cell.col}
{((Number(cell.colSpan) || 1) > 1 || (Number(cell.rowSpan) || 1) > 1) && `, ${Number(cell.colSpan) || 1}x${Number(cell.rowSpan) || 1}`})
</span>
<Button variant="ghost" size="sm" onClick={onRemove} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
{/* 컬럼 + 타입 */}
<div className="flex gap-1">
{cell.type !== "action-buttons" && (
<Select value={cell.columnName || "none"} onValueChange={(v) => onUpdate({ columnName: v === "none" ? "" : v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={cell.type} onValueChange={(v) => onUpdate({ type: v as CardCellType })}>
<SelectTrigger className={cn("h-7 text-[10px]", cell.type === "action-buttons" ? "flex-1" : "w-24")}><SelectValue /></SelectTrigger>
<SelectContent>
{CELL_TYPE_GROUPS.map((group) => {
const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group);
if (types.length === 0) return null;
return (
<Fragment key={group}>
<SelectItem value={`__group_${group}__`} disabled className="text-[8px] font-medium text-muted-foreground">{group}</SelectItem>
{types.map(([key, v]) => (
<SelectItem key={key} value={key} className="text-[10px]">{v.label}</SelectItem>
))}
</Fragment>
);
})}
</SelectContent>
</Select>
</div>
{/* 라벨 + 위치 */}
<div className="flex gap-1">
<Input value={cell.label || ""} onChange={(e) => onUpdate({ label: e.target.value })} placeholder="라벨 (선택)" className="h-7 flex-1 text-[10px]" />
<Select value={cell.labelPosition || "top"} onValueChange={(v) => onUpdate({ labelPosition: v as "top" | "left" })}>
<SelectTrigger className="h-7 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-[10px]"></SelectItem>
<SelectItem value="left" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 크기 + 정렬 */}
<div className="flex gap-1">
<Select value={cell.fontSize || "md"} onValueChange={(v) => onUpdate({ fontSize: v as CardCellDefinitionV2["fontSize"] })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="xs" className="text-[10px]"> </SelectItem>
<SelectItem value="sm" className="text-[10px]"></SelectItem>
<SelectItem value="md" className="text-[10px]"></SelectItem>
<SelectItem value="lg" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
<Select value={cell.align || "left"} onValueChange={(v) => onUpdate({ align: v as CardCellDefinitionV2["align"] })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left" className="text-[10px]"></SelectItem>
<SelectItem value="center" className="text-[10px]"></SelectItem>
<SelectItem value="right" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
<Select value={cell.verticalAlign || "top"} onValueChange={(v) => onUpdate({ verticalAlign: v as CardCellDefinitionV2["verticalAlign"] })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-[10px]"></SelectItem>
<SelectItem value="middle" className="text-[10px]"></SelectItem>
<SelectItem value="bottom" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 타입별 상세 설정 */}
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
{cell.type === "number-input" && (
<div className="space-y-1">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<div className="flex gap-1">
<Input value={cell.inputUnit || ""} onChange={(e) => onUpdate({ inputUnit: e.target.value })} placeholder="단위 (EA)" className="h-7 flex-1 text-[10px]" />
<Select value={cell.limitColumn || "__none__"} onValueChange={(v) => onUpdate({ limitColumn: v === "__none__" ? undefined : v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="제한 컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
)}
{cell.type === "cart-button" && (
<div className="space-y-1">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<div className="flex gap-1">
<Input value={cell.cartLabel || ""} onChange={(e) => onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" />
<Input value={cell.cartCancelLabel || ""} onChange={(e) => onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" />
</div>
</div>
)}
</div>
);
}
// ===== 상태 배지 매핑 에디터 =====
const SEMANTIC_COLORS: Record<string, string> = {
pending: "#64748b", active: "#3b82f6", done: "#10b981",
};
function StatusMappingEditor({
cell,
allCells,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allCells: CardCellDefinitionV2[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => 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<CardCellDefinitionV2> = {
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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium text-muted-foreground">- </span>
<div className="flex gap-1">
{hasTimeline && (
<Button variant="ghost" size="sm" onClick={loadFromTimeline} className="h-5 px-1.5 text-[9px]">
</Button>
)}
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
</div>
{statusMap.map((m, i) => (
<div key={i} className="flex items-center gap-1">
<Input value={m.value} onChange={(e) => updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" />
<Input value={m.label} onChange={(e) => updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
<input type="color" value={m.color} onChange={(e) => updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" />
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
);
}
// ===== 타임라인 설정 =====
function TimelineConfigEditor({
cell,
allColumnOptions,
tables,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
tables: TableInfo[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
const src = cell.timelineSource || { processTable: "", foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" };
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
const [tableOpen, setTableOpen] = useState(false);
useEffect(() => {
if (!src.processTable) { setProcessColumns([]); return; }
fetchTableColumns(src.processTable)
.then(setProcessColumns)
.catch(() => setProcessColumns([]));
}, [src.processTable]);
const updateSource = (partial: Partial<TimelineDataSource>) => {
onUpdate({ timelineSource: { ...src, ...partial } });
};
const colOptions = processColumns.map((c) => ({ value: c.name, label: c.name }));
return (
<div className="space-y-2">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
{/* 하위 테이블 선택 */}
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="mt-0.5 h-7 w-full justify-between text-[10px] font-normal">
{src.processTable || "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-[10px]" />
<CommandList>
<CommandEmpty className="text-[10px]"> .</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.tableName} ${t.displayName || ""}`}
onSelect={() => {
updateSource({ processTable: t.tableName, foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" });
setTableOpen(false);
}}
className="text-[10px]"
>
<Check className={cn("mr-1 h-3 w-3", src.processTable === t.tableName ? "opacity-100" : "opacity-0")} />
{t.displayName || t.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 매핑 (하위 테이블 선택 후) */}
{src.processTable && processColumns.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px] text-muted-foreground"> </Label>
<div className="grid grid-cols-2 gap-1">
<div>
<span className="text-[8px] text-muted-foreground"> FK</span>
<Select value={src.foreignKey || "__none__"} onValueChange={(v) => updateSource({ foreignKey: v === "__none__" ? "" : v })}>
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<span className="text-[8px] text-muted-foreground"></span>
<Select value={src.seqColumn || "__none__"} onValueChange={(v) => updateSource({ seqColumn: v === "__none__" ? "" : v })}>
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<span className="text-[8px] text-muted-foreground"></span>
<Select value={src.nameColumn || "__none__"} onValueChange={(v) => updateSource({ nameColumn: v === "__none__" ? "" : v })}>
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<span className="text-[8px] text-muted-foreground"></span>
<Select value={src.statusColumn || "__none__"} onValueChange={(v) => updateSource({ statusColumn: v === "__none__" ? "" : v })}>
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{/* 상태 값 매핑 (동적 배열) */}
{src.processTable && src.statusColumn && (
<StatusMappingsEditor
mappings={src.statusMappings || []}
onChange={(mappings) => updateSource({ statusMappings: mappings })}
/>
)}
{/* 구분선 */}
<div className="border-t pt-2">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1">
<span className="w-20 shrink-0 text-[9px] text-muted-foreground"> </span>
<Input
type="number"
min={3}
max={10}
value={cell.visibleCount || 5}
onChange={(e) => onUpdate({ visibleCount: parseInt(e.target.value) || 5 })}
className="h-7 w-16 text-[10px]"
/>
<Select
value={cell.timelinePriority || "before"}
onValueChange={(v) => onUpdate({ timelinePriority: v as "before" | "after" })}
>
<SelectTrigger className="h-7 w-[72px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="before"> </SelectItem>
<SelectItem value="after"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Label className="w-20 shrink-0 text-[9px] text-muted-foreground"> </Label>
<Switch
checked={cell.currentHighlight !== false}
onCheckedChange={(v) => onUpdate({ currentHighlight: v })}
/>
</div>
<div className="flex items-center gap-1">
<Label className="w-20 shrink-0 text-[9px] text-muted-foreground"> </Label>
<Switch
checked={cell.showDetailModal !== false}
onCheckedChange={(v) => onUpdate({ showDetailModal: v })}
/>
<span className="text-[8px] text-muted-foreground"> </span>
</div>
</div>
);
}
// ===== 상태 값 매핑 에디터 (동적 배열) =====
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<StatusValueMapping>) => {
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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[9px] text-muted-foreground"> </Label>
<div className="flex gap-1">
{mappings.length === 0 && (
<Button variant="ghost" size="sm" onClick={applyDefaults} className="h-5 px-1.5 text-[9px]">
</Button>
)}
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
</div>
<p className="text-[8px] text-muted-foreground">DB , , (//) .</p>
{mappings.map((m, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={m.dbValue}
onChange={(e) => updateMapping(i, { dbValue: e.target.value })}
placeholder="DB 값"
className="h-6 flex-1 text-[10px]"
/>
<Input
value={m.label}
onChange={(e) => updateMapping(i, { label: e.target.value })}
placeholder="라벨"
className="h-6 flex-1 text-[10px]"
/>
<Select value={m.semantic} onValueChange={(v) => updateMapping(i, { semantic: v as TimelineStatusSemantic })}>
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
{SEMANTIC_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex shrink-0 cursor-pointer items-center gap-0.5" title="기준 상태 (타임라인 강조 + 상태 판별 기준)">
<input
type="radio"
name="isDerived"
checked={!!m.isDerived}
onChange={() => {
const wasChecked = !!m.isDerived;
onChange(mappings.map((item, idx) => ({
...item,
isDerived: idx === i && !wasChecked ? true : undefined,
})));
}}
className="h-3 w-3"
/>
<span className="text-[8px] text-muted-foreground"></span>
</label>
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
);
}
// ===== 액션 버튼 에디터 (버튼 중심 구조) =====
function ActionButtonsEditor({
cell,
allCells,
allColumnOptions,
availableTableOptions,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allCells: CardCellDefinitionV2[];
allColumnOptions: { value: string; label: string }[];
availableTableOptions: { value: string; label: string }[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => 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<ActionButtonDef>) => {
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<ActionButtonShowCondition>) => {
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<ActionButtonClickAction>) => {
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<ActionButtonUpdate>) => {
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<SelectModeButtonConfig>) => {
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<ActionButtonUpdate>) => {
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<Set<number>>(() => {
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<Record<string, boolean>>(() => {
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<string, string> = { 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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<Button variant="ghost" size="sm" onClick={addButtonAndExpand} className="h-5 px-1.5 text-[9px]">
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
{buttons.length === 0 && (
<p className="text-[8px] text-muted-foreground"> . .</p>
)}
{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 (
<div key={bi} className="rounded border">
{/* 접기/펼치기 헤더 */}
<div
className="flex cursor-pointer items-center gap-1 p-1.5 hover:bg-muted/40"
onClick={() => toggleBtn(bi)}
>
{isExpanded
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
<span className="shrink-0 text-[9px] font-bold text-muted-foreground">#{bi + 1}</span>
{isExpanded ? (
<>
<Input
value={btn.label}
onChange={(e) => updateBtn(bi, { label: e.target.value })}
onClick={(e) => e.stopPropagation()}
placeholder="라벨"
className="h-6 flex-1 text-[10px]"
/>
<Select value={btn.variant || "default"} onValueChange={(v) => updateBtn(bi, { variant: v as ButtonVariant })}>
<SelectTrigger className="h-6 w-16 text-[10px]" onClick={(e) => e.stopPropagation()}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="default" className="text-[10px]"></SelectItem>
<SelectItem value="outline" className="text-[10px]"></SelectItem>
<SelectItem value="destructive" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
</>
) : (
<span className="flex-1 truncate text-[9px] text-muted-foreground">
{btn.label || "(미입력)"} <span className="text-muted-foreground/60">|</span> {getCondSummary(btn)} <span className="text-muted-foreground/60">|</span> {actionSummary}
</span>
)}
<Button
variant="ghost" size="sm"
onClick={(e) => { e.stopPropagation(); removeBtn(bi); }}
className="h-5 w-5 shrink-0 p-0"
>
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
</Button>
</div>
{/* 펼쳐진 상세 */}
{isExpanded && (
<div className="space-y-0.5 border-t px-2 pb-2">
{/* === 조건 섹션 === */}
<div
className="flex cursor-pointer items-center gap-1 py-1 hover:bg-muted/30"
onClick={() => toggleSection(`${bi}-cond`)}
>
{isSectionOpen(`${bi}-cond`)
? <ChevronDown className="h-2.5 w-2.5 text-muted-foreground" />
: <ChevronRight className="h-2.5 w-2.5 text-muted-foreground" />}
<span className="text-[8px] font-medium text-muted-foreground"></span>
{!isSectionOpen(`${bi}-cond`) && (
<span className="truncate text-[8px] text-muted-foreground/70">{getCondSummary(btn)}</span>
)}
</div>
{isSectionOpen(`${bi}-cond`) && (
<div className="space-y-1 pl-3">
<div className="flex items-center gap-1">
<span className="w-10 shrink-0 text-[8px] text-muted-foreground"></span>
<Select value={condType} onValueChange={(v) => updateCondition(bi, { type: v as ActionButtonShowCondition["type"] })}>
<SelectTrigger className="h-6 w-[80px] text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="always" className="text-[10px]"></SelectItem>
<SelectItem value="timeline-status" className="text-[10px]"></SelectItem>
<SelectItem value="column-value" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
{condType === "timeline-status" && (
<Select
value={btn.showCondition?.value || "__none__"}
onValueChange={(v) => updateCondition(bi, { value: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="상태 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{statusOptions.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label} ({o.value})</SelectItem>
))}
</SelectContent>
</Select>
)}
{condType === "column-value" && (
<>
<Select
value={btn.showCondition?.column || "__none__"}
onValueChange={(v) => updateCondition(bi, { column: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={btn.showCondition?.value || ""}
onChange={(e) => updateCondition(bi, { value: e.target.value })}
placeholder="값"
className="h-6 w-20 text-[10px]"
/>
</>
)}
</div>
{condType !== "always" && (
<div className="flex items-center gap-1">
<span className="w-10 shrink-0 text-[8px] text-muted-foreground"> </span>
<Select
value={btn.showCondition?.unmatchBehavior || "hidden"}
onValueChange={(v) => updateCondition(bi, { unmatchBehavior: v as "hidden" | "disabled" })}
>
<SelectTrigger className="h-6 w-[80px] text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="hidden" className="text-[10px]"></SelectItem>
<SelectItem value="disabled" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
<span className="text-[8px] text-muted-foreground">
{(btn.showCondition?.unmatchBehavior || "hidden") === "disabled"
? "보이지만 클릭 불가"
: "버튼 안 보임"}
</span>
</div>
)}
</div>
)}
{/* === 실행 섹션 (다중 액션) === */}
<div
className="flex cursor-pointer items-center gap-1 py-1 hover:bg-muted/30"
onClick={() => toggleSection(`${bi}-action`)}
>
{isSectionOpen(`${bi}-action`)
? <ChevronDown className="h-2.5 w-2.5 text-muted-foreground" />
: <ChevronRight className="h-2.5 w-2.5 text-muted-foreground" />}
<span className="text-[8px] font-medium text-muted-foreground"> ({actions.length})</span>
{!isSectionOpen(`${bi}-action`) && (
<span className="truncate text-[8px] text-muted-foreground/70">{actionSummary}</span>
)}
</div>
{isSectionOpen(`${bi}-action`) && (
<div className="space-y-1.5 pl-1">
{actions.map((action, ai) => {
const aType = action.type;
return (
<div key={ai} className="space-y-1 rounded border bg-muted/20 p-1.5">
<div className="flex items-center gap-1">
<span className="shrink-0 text-[8px] font-bold text-muted-foreground">#{ai + 1}</span>
<Select value={aType} onValueChange={(v) => updateAction(bi, ai, { type: v as ActionButtonClickAction["type"] })}>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate" className="text-[10px]"> </SelectItem>
<SelectItem value="select-mode" className="text-[10px]"> </SelectItem>
<SelectItem value="modal-open" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
{actions.length > 1 && (
<Button variant="ghost" size="sm" onClick={() => removeAction(bi, ai)} className="h-5 w-5 shrink-0 p-0">
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
</Button>
)}
</div>
{aType === "immediate" && (
<ImmediateActionEditor
action={action}
allColumnOptions={allColumnOptions}
availableTableOptions={availableTableOptions}
onAddUpdate={() => 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" && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[8px] font-medium text-muted-foreground"> </span>
<Button variant="ghost" size="sm" onClick={() => addSelectModeBtn(bi, ai)} className="h-4 px-1 text-[8px]">
<Plus className="mr-0.5 h-2.5 w-2.5" />
</Button>
</div>
{(action.selectModeButtons || []).map((smBtn, si) => (
<div key={si} className="space-y-1 rounded border bg-muted/30 p-1.5">
<div className="flex items-center gap-1">
<Input value={smBtn.label} onChange={(e) => updateSelectModeBtn(bi, ai, si, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
<Select value={smBtn.variant || "outline"} onValueChange={(v) => updateSelectModeBtn(bi, ai, si, { variant: v as ButtonVariant })}>
<SelectTrigger className="h-6 w-14 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="default" className="text-[10px]"></SelectItem>
<SelectItem value="outline" className="text-[10px]"></SelectItem>
<SelectItem value="destructive" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => removeSelectModeBtn(bi, ai, si)} className="h-5 w-5 p-0">
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
</Button>
</div>
<div className="flex items-center gap-1">
<span className="w-8 shrink-0 text-[8px] text-muted-foreground"></span>
<Select value={smBtn.clickMode} onValueChange={(v) => updateSelectModeBtn(bi, ai, si, { clickMode: v as SelectModeButtonConfig["clickMode"] })}>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="cancel-select" className="text-[10px]"> </SelectItem>
<SelectItem value="status-change" className="text-[10px]"> </SelectItem>
<SelectItem value="modal-open" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
</div>
{smBtn.clickMode === "status-change" && (
<ImmediateActionEditor
action={{ type: "immediate", targetTable: smBtn.targetTable, updates: smBtn.updates, confirmMessage: smBtn.confirmMessage }}
allColumnOptions={allColumnOptions}
availableTableOptions={availableTableOptions}
onAddUpdate={() => 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" && (
<div className="flex items-center gap-1">
<span className="w-10 shrink-0 text-[8px] text-muted-foreground">POP </span>
<Input
value={smBtn.modalScreenId || ""}
onChange={(e) => updateSelectModeBtn(bi, ai, si, { modalScreenId: e.target.value })}
placeholder="화면 ID (예: 4481)"
className="h-6 flex-1 text-[10px]"
/>
</div>
)}
</div>
))}
{(!action.selectModeButtons || action.selectModeButtons.length === 0) && (
<p className="text-[8px] text-muted-foreground"> .</p>
)}
</div>
)}
{aType === "modal-open" && (
<div className="flex items-center gap-1">
<span className="w-10 shrink-0 text-[8px] text-muted-foreground">POP </span>
<Input
value={action.modalScreenId || ""}
onChange={(e) => updateAction(bi, ai, { modalScreenId: e.target.value })}
placeholder="화면 ID (예: 4481)"
className="h-6 flex-1 text-[10px]"
/>
</div>
)}
</div>
);
})}
<Button variant="ghost" size="sm" onClick={() => addAction(bi)} className="h-5 w-full text-[8px]">
<Plus className="mr-0.5 h-2.5 w-2.5" />
</Button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
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<ActionButtonUpdate>) => void;
onRemoveUpdate: (uIdx: number) => void;
onUpdateAction: (partial: Partial<ActionButtonClickAction>) => void;
}) {
const isExternalTable = action.targetTable && !availableTableOptions.some((t) => t.value === action.targetTable);
const [dbSelectMode, setDbSelectMode] = useState(!!isExternalTable);
const [allTables, setAllTables] = useState<TableInfo[]>([]);
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 (
<div className="space-y-1 pl-0.5">
{/* 대상 테이블 */}
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
{!dbSelectMode ? (
<Select
value={action.targetTable || "__none__"}
onValueChange={(v) => {
if (v === "__db_select__") {
setDbSelectMode(true);
onUpdateAction({ targetTable: "" });
} else {
onUpdateAction({ targetTable: v === "__none__" ? "" : v, joinConfig: undefined });
}
}}
>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="테이블 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{availableTableOptions.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-[10px]">{t.label}</SelectItem>
))}
<SelectItem value="__db_select__" className="text-[10px] font-medium text-primary">DB에서 ...</SelectItem>
</SelectContent>
</Select>
) : (
<div className="flex flex-1 items-center gap-1">
<DbTableCombobox
value={action.targetTable || ""}
tables={allTables}
onSelect={(v) => onUpdateAction({ targetTable: v })}
/>
<Button
variant="ghost" size="sm"
onClick={() => { setDbSelectMode(false); onUpdateAction({ targetTable: "", joinConfig: undefined }); }}
className="h-5 shrink-0 px-1 text-[8px]"
>
</Button>
</div>
)}
</div>
{/* 외부 DB 선택 시 조인키 설정 */}
{dbSelectMode && action.targetTable && (
<>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Select
value={action.joinConfig?.sourceColumn || "__none__"}
onValueChange={(v) => onUpdateAction({ joinConfig: { ...action.joinConfig || { sourceColumn: "", targetColumn: "" }, sourceColumn: v === "__none__" ? "" : v } })}
>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="메인 테이블 컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{mainCols.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-[10px]">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Select
value={action.joinConfig?.targetColumn || "__none__"}
onValueChange={(v) => onUpdateAction({ joinConfig: { ...action.joinConfig || { sourceColumn: "", targetColumn: "" }, targetColumn: v === "__none__" ? "" : v } })}
>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="외부 테이블 컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{[...businessCols, ...systemCols].map((c) => (
<SelectItem key={c.value} value={c.value} className="text-[10px]">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-[7px] text-muted-foreground/70 pl-0.5">
. = .
</p>
</>
)}
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Input
value={action.confirmMessage || ""}
onChange={(e) => onUpdateAction({ confirmMessage: e.target.value })}
placeholder="처리하시겠습니까?"
className="h-6 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] font-medium text-muted-foreground">
{tableName ? ` (${tableName})` : ""}
</span>
<Button variant="ghost" size="sm" onClick={onAddUpdate} className="h-4 px-1 text-[8px]">
<Plus className="mr-0.5 h-2.5 w-2.5" />
</Button>
</div>
{(action.updates || []).map((u, ui) => (
<div key={ui} className="flex items-center gap-1">
<Select value={u.column || "__none__"} onValueChange={(v) => onUpdateUpdate(ui, { column: v === "__none__" ? "" : v })}>
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{businessCols.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[8px] text-muted-foreground">{tableName}</SelectLabel>
{businessCols.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectGroup>
)}
{systemCols.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[8px] text-muted-foreground">{tableName} - </SelectLabel>
{systemCols.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<Select value={u.valueType} onValueChange={(v) => onUpdateUpdate(ui, { valueType: v as ActionButtonUpdate["valueType"] })}>
<SelectTrigger className="h-6 w-20 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="static" className="text-[10px]"></SelectItem>
<SelectItem value="currentUser" className="text-[10px]"> </SelectItem>
<SelectItem value="currentTime" className="text-[10px]"> </SelectItem>
<SelectItem value="columnRef" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
{(u.valueType === "static" || u.valueType === "columnRef") && (
<Input
value={u.value || ""}
onChange={(e) => onUpdateUpdate(ui, { value: e.target.value })}
placeholder={u.valueType === "static" ? "값" : "컬럼명"}
className="h-6 flex-1 text-[10px]"
/>
)}
<Button variant="ghost" size="sm" onClick={() => onRemoveUpdate(ui)} className="h-5 w-5 shrink-0 p-0">
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
</Button>
</div>
))}
{(!action.updates || action.updates.length === 0) && (
<p className="text-[8px] text-muted-foreground"> DB가 .</p>
)}
</div>
);
}
// ===== 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-6 flex-1 justify-between text-[10px] font-normal"
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)", minWidth: 240 }}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="테이블명 또는 설명 검색..."
className="text-[10px]"
value={search}
onValueChange={setSearch}
/>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-[10px] text-muted-foreground">
.
</CommandEmpty>
<CommandGroup>
{filtered.map((t) => (
<CommandItem
key={t.tableName}
value={t.tableName}
onSelect={() => {
onSelect(t.tableName);
setOpen(false);
setSearch("");
}}
className="text-[10px]"
>
<Check className={cn("mr-1.5 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{t.tableName}</span>
{t.tableComment && (
<span className="ml-1 text-muted-foreground">({t.tableComment})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ===== 하단 상태 에디터 =====
function FooterStatusEditor({
cell,
allColumnOptions,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => 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 (
<div className="space-y-1">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<div className="flex gap-1">
<Input
value={cell.footerLabel || ""}
onChange={(e) => onUpdate({ footerLabel: e.target.value })}
placeholder="라벨 (예: 검사의뢰)"
className="h-7 flex-1 text-[10px]"
/>
</div>
<div className="flex gap-1">
<Select value={cell.footerStatusColumn || "__none__"} onValueChange={(v) => onUpdate({ footerStatusColumn: v === "__none__" ? undefined : v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="상태 컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Label className="text-[9px] text-muted-foreground"> </Label>
<Switch
checked={cell.showTopBorder !== false}
onCheckedChange={(v) => onUpdate({ showTopBorder: v })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium text-muted-foreground">- </span>
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
{footerStatusMap.map((m, i) => (
<div key={i} className="flex items-center gap-1">
<Input value={m.value} onChange={(e) => updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" />
<Input value={m.label} onChange={(e) => updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
<input type="color" value={m.color} onChange={(e) => updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" />
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
);
}
// ===== 필드 설정 에디터 =====
function FieldConfigEditor({
cell,
allColumnOptions,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
const valueType = cell.valueType || "column";
return (
<div className="space-y-1">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<div className="flex gap-1">
<Select value={valueType} onValueChange={(v) => onUpdate({ valueType: v as "column" | "formula" })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="column" className="text-[10px]"> </SelectItem>
<SelectItem value="formula" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
<Input value={cell.unit || ""} onChange={(e) => onUpdate({ unit: e.target.value })} placeholder="단위" className="h-7 w-16 text-[10px]" />
</div>
{valueType === "formula" && (
<div className="flex gap-1">
<Select value={cell.formulaLeft || ""} onValueChange={(v) => onUpdate({ formulaLeft: v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="좌항" /></SelectTrigger>
<SelectContent>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Select value={cell.formulaOperator || "+"} onValueChange={(v) => onUpdate({ formulaOperator: v as "+" | "-" | "*" | "/" })}>
<SelectTrigger className="h-7 w-12 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
{["+", "-", "*", "/"].map((op) => <SelectItem key={op} value={op} className="text-[10px]">{op}</SelectItem>)}
</SelectContent>
</Select>
<Select value={cell.formulaRightType || "input"} onValueChange={(v) => onUpdate({ formulaRightType: v as "input" | "column" })}>
<SelectTrigger className="h-7 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="input" className="text-[10px]"></SelectItem>
<SelectItem value="column" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
{cell.formulaRightType === "column" && (
<Select value={cell.formulaRight || ""} onValueChange={(v) => onUpdate({ formulaRight: v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="우항" /></SelectTrigger>
<SelectContent>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
</SelectContent>
</Select>
)}
</div>
)}
</div>
);
}
// ===== 탭 3: 동작 =====
function TabActions({
cfg,
onUpdate,
}: {
cfg: PopCardListV2Config;
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
}) {
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
const clickAction = cfg.cardClickAction || "none";
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
return (
<div className="space-y-3">
{/* 카드 선택 시 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 space-y-1">
{(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => (
<label key={action} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<input
type="radio"
name="cardClickAction"
checked={clickAction === action}
onChange={() => onUpdate({ cardClickAction: action })}
className="h-3 w-3"
/>
<span className="text-xs">
{action === "none" && "없음"}
{action === "publish" && "상세 데이터 전달 (다른 컴포넌트 연결)"}
{action === "navigate" && "화면 이동"}
{action === "modal-open" && "모달 열기"}
</span>
</label>
))}
</div>
{clickAction === "modal-open" && (
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">POP ID</span>
<Input
value={modalConfig.screenId || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
placeholder="화면 ID (예: 4481)"
className="h-7 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> </span>
<Input
value={modalConfig.modalTitle || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, modalTitle: e.target.value } })}
placeholder="비우면 '상세 작업' 표시"
className="h-7 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"></span>
<Select
value={modalConfig.condition?.type || "always"}
onValueChange={(v) => {
const cond = v === "always" ? undefined : { type: v as "timeline-status" | "column-value", value: "", column: "" };
onUpdate({ cardClickModalConfig: { ...modalConfig, condition: cond } });
}}
>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="always" className="text-[10px]"></SelectItem>
<SelectItem value="timeline-status" className="text-[10px]"> </SelectItem>
<SelectItem value="column-value" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
</div>
{modalConfig.condition?.type === "timeline-status" && (
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> </span>
<Input
value={modalConfig.condition?.value || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })}
placeholder="예: in_progress"
className="h-7 flex-1 text-[10px]"
/>
</div>
)}
{modalConfig.condition?.type === "column-value" && (
<>
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"></span>
<Input
value={modalConfig.condition?.column || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, column: e.target.value } } })}
placeholder="컬럼명"
className="h-7 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"></span>
<Input
value={modalConfig.condition?.value || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })}
placeholder="값"
className="h-7 flex-1 text-[10px]"
/>
</div>
</>
)}
</div>
)}
</div>
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
.
</p>
)}
{/* 스크롤 방향 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{(["vertical", "horizontal"] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => onUpdate({ scrollDirection: dir })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
(cfg.scrollDirection || "vertical") === dir
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{dir === "vertical" ? "세로" : "가로"}
</button>
))}
</div>
</div>
{/* 오버플로우 */}
<div>
<Label className="text-xs"></Label>
<div className="mt-1 flex gap-1">
{(["loadMore", "pagination"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => onUpdate({ overflow: { ...overflow, mode } })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
overflow.mode === mode
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{mode === "loadMore" ? "더보기" : "페이지네이션"}
</button>
))}
</div>
<div className="mt-2 space-y-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
{overflow.mode === "loadMore" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.loadMoreCount ?? 6}
onChange={(e) => onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
)}
{overflow.mode === "pagination" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={100}
value={overflow.pageSize ?? overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
)}
</div>
</div>
{/* 장바구니 */}
<div className="flex items-center justify-between">
<Label className="text-xs">() </Label>
<Switch
checked={!!cfg.cartAction}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } });
} else {
onUpdate({ cartAction: undefined });
}
}}
/>
</div>
</div>
);
}