ERP-node/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx

1074 lines
32 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-dashboard ()
*
* 3 :
* [ ] - , ,
* [ ] - //,
* [] - grid /
*/
import React, { useState, useEffect, useCallback } from "react";
import {
Plus,
Trash2,
ChevronDown,
ChevronUp,
GripVertical,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type {
PopDashboardConfig,
DashboardItem,
DashboardSubType,
DashboardDisplayMode,
DataSourceConfig,
FormulaConfig,
ItemVisibility,
DashboardCell,
} from "../types";
import { fetchTableColumns, type ColumnInfo } from "./utils/dataFetcher";
import { validateExpression } from "./utils/formula";
// ===== Props =====
interface ConfigPanelProps {
config: PopDashboardConfig | undefined;
onChange: (config: PopDashboardConfig) => void;
}
// ===== 기본값 =====
const DEFAULT_CONFIG: PopDashboardConfig = {
items: [],
displayMode: "arrows",
autoSlideInterval: 5,
autoSlideResumeDelay: 3,
showIndicator: true,
gap: 8,
gridColumns: 2,
gridRows: 2,
gridCells: [],
};
const DEFAULT_VISIBILITY: ItemVisibility = {
showLabel: true,
showValue: true,
showUnit: true,
showTrend: true,
showSubLabel: false,
showTarget: true,
};
const DEFAULT_DATASOURCE: DataSourceConfig = {
tableName: "",
filters: [],
sort: [],
};
// ===== 라벨 상수 =====
const DISPLAY_MODE_LABELS: Record<DashboardDisplayMode, string> = {
arrows: "좌우 버튼",
"auto-slide": "자동 슬라이드",
grid: "그리드",
scroll: "스크롤",
};
const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
"kpi-card": "KPI 카드",
chart: "차트",
gauge: "게이지",
"stat-card": "통계 카드",
};
// ===== 데이터 소스 편집기 =====
function DataSourceEditor({
dataSource,
onChange,
}: {
dataSource: DataSourceConfig;
onChange: (ds: DataSourceConfig) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
// 테이블 변경 시 컬럼 목록 조회
useEffect(() => {
if (!dataSource.tableName) {
setColumns([]);
return;
}
setLoadingCols(true);
fetchTableColumns(dataSource.tableName)
.then(setColumns)
.finally(() => setLoadingCols(false));
}, [dataSource.tableName]);
return (
<div className="space-y-2">
{/* 테이블명 입력 */}
<div>
<Label className="text-xs"></Label>
<Input
value={dataSource.tableName}
onChange={(e) =>
onChange({ ...dataSource, tableName: e.target.value })
}
placeholder="예: production"
className="h-8 text-xs"
/>
</div>
{/* 집계 함수 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={dataSource.aggregation?.type ?? ""}
onValueChange={(val) =>
onChange({
...dataSource,
aggregation: val
? {
type: val as DataSourceConfig["aggregation"] extends undefined ? never : NonNullable<DataSourceConfig["aggregation"]>["type"],
column: dataSource.aggregation?.column ?? "",
}
: undefined,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 집계 대상 컬럼 */}
{dataSource.aggregation && (
<div>
<Label className="text-xs"> </Label>
<Select
value={dataSource.aggregation.column}
onValueChange={(val) =>
onChange({
...dataSource,
aggregation: {
...dataSource.aggregation!,
column: val,
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingCols ? "로딩..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name} ({col.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* 새로고침 주기 */}
<div>
<Label className="text-xs"> (, 0=)</Label>
<Input
type="number"
value={dataSource.refreshInterval ?? 0}
onChange={(e) =>
onChange({
...dataSource,
refreshInterval: parseInt(e.target.value) || 0,
})
}
className="h-8 text-xs"
min={0}
/>
</div>
</div>
);
}
// ===== 수식 편집기 =====
function FormulaEditor({
formula,
onChange,
}: {
formula: FormulaConfig;
onChange: (f: FormulaConfig) => void;
}) {
const availableIds = formula.values.map((v) => v.id);
const isValid = formula.expression
? validateExpression(formula.expression, availableIds)
: true;
return (
<div className="space-y-3 rounded-md border p-2">
<p className="text-xs font-medium"> </p>
{/* 값 목록 */}
{formula.values.map((fv, index) => (
<div key={fv.id} className="space-y-1">
<div className="flex items-center gap-2">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs font-bold text-primary">
{fv.id}
</span>
<Input
value={fv.label}
onChange={(e) => {
const newValues = [...formula.values];
newValues[index] = { ...fv, label: e.target.value };
onChange({ ...formula, values: newValues });
}}
placeholder="라벨 (예: 생산량)"
className="h-7 flex-1 text-xs"
/>
{formula.values.length > 2 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newValues = formula.values.filter((_, i) => i !== index);
onChange({ ...formula, values: newValues });
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<DataSourceEditor
dataSource={fv.dataSource}
onChange={(ds) => {
const newValues = [...formula.values];
newValues[index] = { ...fv, dataSource: ds };
onChange({ ...formula, values: newValues });
}}
/>
</div>
))}
{/* 값 추가 */}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
const nextId = String.fromCharCode(65 + formula.values.length); // A, B, C...
onChange({
...formula,
values: [
...formula.values,
{
id: nextId,
label: "",
dataSource: { ...DEFAULT_DATASOURCE },
},
],
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
{/* 수식 입력 */}
<div>
<Label className="text-xs"></Label>
<Input
value={formula.expression}
onChange={(e) =>
onChange({ ...formula, expression: e.target.value })
}
placeholder="예: A / B * 100"
className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`}
/>
{!isValid && (
<p className="mt-0.5 text-[10px] text-destructive">
</p>
)}
</div>
{/* 표시 형태 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={formula.displayFormat}
onValueChange={(val) =>
onChange({
...formula,
displayFormat: val as FormulaConfig["displayFormat"],
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="value"> </SelectItem>
<SelectItem value="fraction"> (1,234 / 5,678)</SelectItem>
<SelectItem value="percent"> (21.7%)</SelectItem>
<SelectItem value="ratio"> (1,234 : 5,678)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// ===== 아이템 편집기 =====
function ItemEditor({
item,
index,
onUpdate,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: {
item: DashboardItem;
index: number;
onUpdate: (item: DashboardItem) => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const [dataMode, setDataMode] = useState<"single" | "formula">(
item.formula?.enabled ? "formula" : "single"
);
return (
<div className="rounded-md border p-2">
{/* 헤더 */}
<div className="flex items-center gap-1">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 truncate text-xs font-medium">
{item.label || `아이템 ${index + 1}`}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px]">
{SUBTYPE_LABELS[item.subType]}
</span>
{/* 이동 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onMoveUp}
disabled={isFirst}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onMoveDown}
disabled={isLast}
>
<ChevronDown className="h-3 w-3" />
</Button>
{/* 보이기/숨기기 */}
<Switch
checked={item.visible}
onCheckedChange={(checked) =>
onUpdate({ ...item, visible: checked })
}
className="scale-75"
/>
{/* 접기/펼치기 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
{/* 삭제 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 상세 설정 (접힘) */}
{expanded && (
<div className="mt-2 space-y-3">
{/* 라벨 */}
<div>
<Label className="text-xs"></Label>
<Input
value={item.label}
onChange={(e) => onUpdate({ ...item, label: e.target.value })}
className="h-8 text-xs"
placeholder="아이템 이름"
/>
</div>
{/* 서브타입 */}
<div>
<Label className="text-xs"></Label>
<Select
value={item.subType}
onValueChange={(val) =>
onUpdate({ ...item, subType: val as DashboardSubType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kpi-card">KPI </SelectItem>
<SelectItem value="chart"></SelectItem>
<SelectItem value="gauge"></SelectItem>
<SelectItem value="stat-card"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 데이터 모드 선택 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={dataMode}
onValueChange={(val) => {
const mode = val as "single" | "formula";
setDataMode(mode);
if (mode === "formula" && !item.formula) {
onUpdate({
...item,
formula: {
enabled: true,
values: [
{ id: "A", label: "", dataSource: { ...DEFAULT_DATASOURCE } },
{ id: "B", label: "", dataSource: { ...DEFAULT_DATASOURCE } },
],
expression: "A / B",
displayFormat: "value",
},
});
} else if (mode === "single") {
onUpdate({ ...item, formula: undefined });
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single"> </SelectItem>
<SelectItem value="formula"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 데이터 소스 / 수식 편집 */}
{dataMode === "formula" && item.formula ? (
<FormulaEditor
formula={item.formula}
onChange={(f) => onUpdate({ ...item, formula: f })}
/>
) : (
<DataSourceEditor
dataSource={item.dataSource}
onChange={(ds) => onUpdate({ ...item, dataSource: ds })}
/>
)}
{/* 요소별 보이기/숨기기 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 grid grid-cols-2 gap-1">
{(
[
["showLabel", "라벨"],
["showValue", "값"],
["showUnit", "단위"],
["showTrend", "증감율"],
["showSubLabel", "보조라벨"],
["showTarget", "목표값"],
] as const
).map(([key, label]) => (
<label
key={key}
className="flex items-center gap-1.5 text-xs"
>
<input
type="checkbox"
checked={item.visibility[key]}
onChange={(e) =>
onUpdate({
...item,
visibility: {
...item.visibility,
[key]: e.target.checked,
},
})
}
className="h-3 w-3 rounded border-input"
/>
{label}
</label>
))}
</div>
</div>
{/* 서브타입별 추가 설정 */}
{item.subType === "kpi-card" && (
<div>
<Label className="text-xs"></Label>
<Input
value={item.kpiConfig?.unit ?? ""}
onChange={(e) =>
onUpdate({
...item,
kpiConfig: { ...item.kpiConfig, unit: e.target.value },
})
}
placeholder="EA, 톤, 원"
className="h-8 text-xs"
/>
</div>
)}
{item.subType === "chart" && (
<div>
<Label className="text-xs"> </Label>
<Select
value={item.chartConfig?.chartType ?? "bar"}
onValueChange={(val) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: val as "bar" | "pie" | "line",
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"> </SelectItem>
<SelectItem value="pie"> </SelectItem>
<SelectItem value="line"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{item.subType === "gauge" && (
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={item.gaugeConfig?.min ?? 0}
onChange={(e) =>
onUpdate({
...item,
gaugeConfig: {
min: parseInt(e.target.value) || 0,
max: item.gaugeConfig?.max ?? 100,
...item.gaugeConfig,
},
})
}
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={item.gaugeConfig?.max ?? 100}
onChange={(e) =>
onUpdate({
...item,
gaugeConfig: {
min: item.gaugeConfig?.min ?? 0,
max: parseInt(e.target.value) || 100,
...item.gaugeConfig,
},
})
}
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={item.gaugeConfig?.target ?? ""}
onChange={(e) =>
onUpdate({
...item,
gaugeConfig: {
min: item.gaugeConfig?.min ?? 0,
max: item.gaugeConfig?.max ?? 100,
...item.gaugeConfig,
target: parseInt(e.target.value) || undefined,
},
})
}
className="h-8 text-xs"
/>
</div>
</div>
)}
</div>
)}
</div>
);
}
// ===== 그리드 레이아웃 편집기 =====
function GridLayoutEditor({
cells,
gridColumns,
gridRows,
items,
onChange,
}: {
cells: DashboardCell[];
gridColumns: number;
gridRows: number;
items: DashboardItem[];
onChange: (
cells: DashboardCell[],
cols: number,
rows: number
) => void;
}) {
// 셀이 없으면 기본 그리드 생성
const ensuredCells =
cells.length > 0
? cells
: Array.from({ length: gridColumns * gridRows }, (_, i) => ({
id: `cell-${i}`,
gridColumn: `${(i % gridColumns) + 1} / ${(i % gridColumns) + 2}`,
gridRow: `${Math.floor(i / gridColumns) + 1} / ${Math.floor(i / gridColumns) + 2}`,
itemId: null as string | null,
}));
return (
<div className="space-y-3">
{/* 열/행 수 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={gridColumns}
onChange={(e) => {
const newCols = Math.max(1, parseInt(e.target.value) || 1);
onChange(ensuredCells, newCols, gridRows);
}}
className="h-8 text-xs"
min={1}
max={6}
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={gridRows}
onChange={(e) => {
const newRows = Math.max(1, parseInt(e.target.value) || 1);
onChange(ensuredCells, gridColumns, newRows);
}}
className="h-8 text-xs"
min={1}
max={6}
/>
</div>
</div>
{/* 셀 미리보기 + 아이템 배정 */}
<div
className="gap-1 rounded border p-2"
style={{
display: "grid",
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gridTemplateRows: `repeat(${gridRows}, 40px)`,
}}
>
{ensuredCells.map((cell) => (
<div
key={cell.id}
className="rounded border border-dashed border-muted-foreground/30 p-0.5"
style={{
gridColumn: cell.gridColumn,
gridRow: cell.gridRow,
}}
>
<Select
value={cell.itemId ?? "empty"}
onValueChange={(val) => {
const newCells = ensuredCells.map((c) =>
c.id === cell.id
? { ...c, itemId: val === "empty" ? null : val }
: c
);
onChange(newCells, gridColumns, gridRows);
}}
>
<SelectTrigger className="h-full w-full border-0 p-0 text-[10px]">
<SelectValue placeholder="빈 셀" />
</SelectTrigger>
<SelectContent>
<SelectItem value="empty"> </SelectItem>
{items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label || item.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{/* 셀 재생성 */}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={() => {
const newCells: DashboardCell[] = [];
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridColumns; c++) {
newCells.push({
id: `cell-${r}-${c}`,
gridColumn: `${c + 1} / ${c + 2}`,
gridRow: `${r + 1} / ${r + 2}`,
itemId: null,
});
}
}
onChange(newCells, gridColumns, gridRows);
}}
>
</Button>
</div>
);
}
// ===== 메인 설정 패널 =====
export function PopDashboardConfigPanel({
config,
onChange,
}: ConfigPanelProps) {
const cfg = config ?? DEFAULT_CONFIG;
const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">(
"basic"
);
// 설정 변경 헬퍼
const updateConfig = useCallback(
(partial: Partial<PopDashboardConfig>) => {
onChange({ ...cfg, ...partial });
},
[cfg, onChange]
);
// 아이템 추가
const addItem = useCallback(
(subType: DashboardSubType) => {
const newItem: DashboardItem = {
id: `item-${Date.now()}`,
label: `${SUBTYPE_LABELS[subType]} ${cfg.items.length + 1}`,
visible: true,
subType,
dataSource: { ...DEFAULT_DATASOURCE },
visibility: { ...DEFAULT_VISIBILITY },
};
updateConfig({ items: [...cfg.items, newItem] });
},
[cfg.items, updateConfig]
);
// 아이템 업데이트
const updateItem = useCallback(
(index: number, item: DashboardItem) => {
const newItems = [...cfg.items];
newItems[index] = item;
updateConfig({ items: newItems });
},
[cfg.items, updateConfig]
);
// 아이템 삭제 (grid 셀 배정도 해제)
const deleteItem = useCallback(
(index: number) => {
const deletedId = cfg.items[index].id;
const newItems = cfg.items.filter((_, i) => i !== index);
// grid 셀에서 해당 아이템 배정 해제
const newCells = cfg.gridCells?.map((cell) =>
cell.itemId === deletedId ? { ...cell, itemId: null } : cell
);
updateConfig({ items: newItems, gridCells: newCells });
},
[cfg.items, cfg.gridCells, updateConfig]
);
// 아이템 순서 변경
const moveItem = useCallback(
(from: number, to: number) => {
if (to < 0 || to >= cfg.items.length) return;
const newItems = [...cfg.items];
const [moved] = newItems.splice(from, 1);
newItems.splice(to, 0, moved);
updateConfig({ items: newItems });
},
[cfg.items, updateConfig]
);
return (
<div className="space-y-3">
{/* 탭 헤더 */}
<div className="flex gap-1 border-b pb-1">
{(
[
["basic", "기본 설정"],
["items", "아이템"],
["layout", "레이아웃"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
className={`rounded-t px-2 py-1 text-xs font-medium transition-colors ${
activeTab === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
{/* ===== 기본 설정 탭 ===== */}
{activeTab === "basic" && (
<div className="space-y-3">
{/* 표시 모드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={cfg.displayMode}
onValueChange={(val) =>
updateConfig({
displayMode: val as DashboardDisplayMode,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DISPLAY_MODE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 자동 슬라이드 설정 */}
{cfg.displayMode === "auto-slide" && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> ()</Label>
<Input
type="number"
value={cfg.autoSlideInterval ?? 5}
onChange={(e) =>
updateConfig({
autoSlideInterval: parseInt(e.target.value) || 5,
})
}
className="h-8 text-xs"
min={1}
/>
</div>
<div>
<Label className="text-xs"> ()</Label>
<Input
type="number"
value={cfg.autoSlideResumeDelay ?? 3}
onChange={(e) =>
updateConfig({
autoSlideResumeDelay: parseInt(e.target.value) || 3,
})
}
className="h-8 text-xs"
min={1}
/>
</div>
</div>
)}
{/* 인디케이터 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={cfg.showIndicator ?? true}
onCheckedChange={(checked) =>
updateConfig({ showIndicator: checked })
}
/>
</div>
{/* 간격 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={cfg.gap ?? 8}
onChange={(e) =>
updateConfig({ gap: parseInt(e.target.value) || 8 })
}
className="h-8 text-xs"
min={0}
/>
</div>
{/* 배경색 */}
<div>
<Label className="text-xs"></Label>
<Input
value={cfg.backgroundColor ?? ""}
onChange={(e) =>
updateConfig({ backgroundColor: e.target.value || undefined })
}
placeholder="예: #f0f0f0"
className="h-8 text-xs"
/>
</div>
</div>
)}
{/* ===== 아이템 관리 탭 ===== */}
{activeTab === "items" && (
<div className="space-y-2">
{/* 아이템 목록 */}
{cfg.items.map((item, index) => (
<ItemEditor
key={item.id}
item={item}
index={index}
onUpdate={(updated) => updateItem(index, updated)}
onDelete={() => deleteItem(index)}
onMoveUp={() => moveItem(index, index - 1)}
onMoveDown={() => moveItem(index, index + 1)}
isFirst={index === 0}
isLast={index === cfg.items.length - 1}
/>
))}
{/* 아이템 추가 버튼 */}
<div className="grid grid-cols-2 gap-1">
{(Object.keys(SUBTYPE_LABELS) as DashboardSubType[]).map(
(subType) => (
<Button
key={subType}
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => addItem(subType)}
>
<Plus className="mr-1 h-3 w-3" />
{SUBTYPE_LABELS[subType]}
</Button>
)
)}
</div>
</div>
)}
{/* ===== 레이아웃 탭 (grid 모드 전용) ===== */}
{activeTab === "layout" && (
<div>
{cfg.displayMode === "grid" ? (
<GridLayoutEditor
cells={cfg.gridCells ?? []}
gridColumns={cfg.gridColumns ?? 2}
gridRows={cfg.gridRows ?? 2}
items={cfg.items}
onChange={(cells, cols, rows) =>
updateConfig({
gridCells: cells,
gridColumns: cols,
gridRows: rows,
})
}
/>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-muted-foreground">
.
<br />
&quot;&quot; .
</p>
</div>
)}
</div>
)}
</div>
);
}