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

2195 lines
92 KiB
TypeScript

"use client";
/**
* pop-card-list-v2 설정 패널 (3탭)
*
* 탭 1: 데이터 — 테이블/컬럼 선택, 조인, 정렬
* 탭 2: 카드 디자인 — 열 수, 시각적 그리드 디자이너, 셀 클릭 시 타입별 상세 인라인
* 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트
*/
import { useState, useEffect, useRef, useCallback, 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,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Check, ChevronsUpDown, Plus, Minus, Trash2 } 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,
ActionButtonUpdate,
TimelineDataSource,
StatusValueMapping,
TimelineStatusSemantic,
} 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}
allColumnOptions={allColumnOptions}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
onUpdate={(partial) => updateCell(selectedCell.id, partial)}
onRemove={() => removeCell(selectedCell.id)}
/>
)}
</div>
);
}
// ===== 셀 상세 에디터 (타입별 인라인) =====
function CellDetailEditor({
cell,
allColumnOptions,
columns,
selectedColumns,
tables,
onUpdate,
onRemove,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
columns: ColumnInfo[];
selectedColumns: string[];
tables: TableInfo[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
onRemove: () => void;
}) {
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">
<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="h-7 w-24 text-[10px]"><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} onUpdate={onUpdate} />}
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allColumnOptions={allColumnOptions} 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>
);
}
// ===== 상태 배지 매핑 에디터 =====
function StatusMappingEditor({
cell,
onUpdate,
}: {
cell: CardCellDefinitionV2;
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
const statusMap = cell.statusMap || [];
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>
<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>
{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>
<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,
allColumnOptions,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
const rules = cell.actionRules || [];
const [expandedBtn, setExpandedBtn] = useState<string | null>(null);
const cloneRules = () => rules.map((r) => ({ ...r, buttons: r.buttons.map((b) => ({ ...b })) }));
const addRule = () => {
onUpdate({
actionRules: [
...rules,
{ whenStatus: "", buttons: [{ label: "", variant: "default" as ButtonVariant, taskPreset: "" }] },
],
});
};
const updateRule = (index: number, partial: Partial<{ whenStatus: string }>) => {
onUpdate({ actionRules: rules.map((r, i) => (i === index ? { ...r, ...partial } : r)) });
};
const removeRule = (index: number) => {
onUpdate({ actionRules: rules.filter((_, i) => i !== index) });
setExpandedBtn(null);
};
const addButton = (ruleIndex: number) => {
const nr = cloneRules();
nr[ruleIndex].buttons.push({ label: "", variant: "default" as ButtonVariant, taskPreset: "" });
onUpdate({ actionRules: nr });
};
const updateButton = (ruleIndex: number, btnIndex: number, partial: Record<string, unknown>) => {
const nr = cloneRules();
nr[ruleIndex].buttons[btnIndex] = { ...nr[ruleIndex].buttons[btnIndex], ...partial };
onUpdate({ actionRules: nr });
};
const removeButton = (ruleIndex: number, btnIndex: number) => {
const nr = cloneRules();
nr[ruleIndex].buttons = nr[ruleIndex].buttons.filter((_, i) => i !== btnIndex);
onUpdate({ actionRules: nr });
setExpandedBtn(null);
};
// updates 배열 관리
const addUpdate = (ri: number, bi: number) => {
const nr = cloneRules();
const btn = nr[ri].buttons[bi];
btn.updates = [...(btn.updates || []), { column: "", value: "", valueType: "static" as const }];
onUpdate({ actionRules: nr });
};
const updateUpdateEntry = (ri: number, bi: number, ui: number, partial: Partial<ActionButtonUpdate>) => {
const nr = cloneRules();
const btn = nr[ri].buttons[bi];
btn.updates = (btn.updates || []).map((u, i) => (i === ui ? { ...u, ...partial } : u));
onUpdate({ actionRules: nr });
};
const removeUpdate = (ri: number, bi: number, ui: number) => {
const nr = cloneRules();
const btn = nr[ri].buttons[bi];
btn.updates = (btn.updates || []).filter((_, i) => i !== ui);
onUpdate({ actionRules: nr });
};
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<Button variant="ghost" size="sm" onClick={addRule} className="h-5 px-1.5 text-[9px]">
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
{rules.map((rule, ri) => (
<div key={ri} className="space-y-1 rounded border p-1.5">
<div className="flex items-center gap-1">
<span className="text-[8px] text-muted-foreground">:</span>
<Input value={rule.whenStatus} onChange={(e) => updateRule(ri, { whenStatus: e.target.value })} placeholder="상태값 (예: waiting)" className="h-6 flex-1 text-[10px]" />
<Button variant="ghost" size="sm" onClick={() => removeRule(ri)} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
{rule.buttons.map((btn, bi) => {
const btnKey = `${ri}-${bi}`;
const isExpanded = expandedBtn === btnKey;
return (
<div key={bi} className="space-y-1 pl-2">
<div className="flex items-center gap-1">
<Input value={btn.label} onChange={(e) => updateButton(ri, bi, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
<Select value={btn.variant || "default"} onValueChange={(v) => updateButton(ri, bi, { variant: v })}>
<SelectTrigger className="h-6 w-20 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>
<SelectItem value="secondary" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost" size="sm"
onClick={() => setExpandedBtn(isExpanded ? null : btnKey)}
className={cn("h-5 w-5 p-0", isExpanded && "bg-primary/10")}
>
<ChevronsUpDown className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeButton(ri, bi)} className="h-5 w-5 p-0">
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
</Button>
</div>
{isExpanded && (
<div className="ml-1 space-y-1 rounded border border-dashed p-1.5">
<span className="text-[8px] font-medium text-muted-foreground"> </span>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Input
value={btn.targetTable || ""}
onChange={(e) => updateButton(ri, bi, { targetTable: e.target.value })}
placeholder="work_order_process"
className="h-6 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Input
value={btn.confirmMessage || ""}
onChange={(e) => updateButton(ri, bi, { 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"> </span>
<Button variant="ghost" size="sm" onClick={() => addUpdate(ri, bi)} className="h-4 px-1 text-[8px]">
<Plus className="mr-0.5 h-2.5 w-2.5" />
</Button>
</div>
{(btn.updates || []).map((u, ui) => (
<div key={ui} className="flex items-center gap-1">
<Select value={u.column || "__none__"} onValueChange={(v) => updateUpdateEntry(ri, bi, ui, { 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>
<Select value={u.valueType} onValueChange={(v) => updateUpdateEntry(ri, bi, 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) => updateUpdateEntry(ri, bi, ui, { value: e.target.value })}
placeholder={u.valueType === "static" ? "값" : "컬럼명"}
className="h-6 flex-1 text-[10px]"
/>
)}
<Button variant="ghost" size="sm" onClick={() => removeUpdate(ri, bi, ui)} className="h-5 w-5 shrink-0 p-0">
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
</Button>
</div>
))}
{(!btn.updates || btn.updates.length === 0) && (
<p className="text-[8px] text-muted-foreground"> DB가 .</p>
)}
</div>
)}
</div>
);
})}
<Button variant="ghost" size="sm" onClick={() => addButton(ri)} className="ml-2 h-5 text-[8px]">
<Plus className="mr-0.5 h-2.5 w-2.5" />
</Button>
</div>
))}
</div>
);
}
// ===== 하단 상태 에디터 =====
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";
return (
<div className="space-y-3">
{/* 카드 선택 시 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 space-y-1">
{(["none", "publish", "navigate"] 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" && "화면 이동"}
</span>
</label>
))}
</div>
</div>
{/* 스크롤 방향 */}
<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>
);
}