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

2339 lines
75 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-dashboard ()
*
* 3 :
* [ ] - , ,
* [ ] - //,
* [] - () /,
*/
import React, { useState, useEffect, useCallback } from "react";
import {
Plus,
Trash2,
ChevronDown,
ChevronUp,
GripVertical,
Check,
ChevronsUpDown,
Eye,
} 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 {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type {
PopDashboardConfig,
DashboardItem,
DashboardSubType,
DashboardDisplayMode,
DataSourceConfig,
DataSourceFilter,
FilterOperator,
FormulaConfig,
ItemVisibility,
DashboardCell,
DashboardPage,
JoinConfig,
JoinType,
ItemStyleConfig,
AggregationType,
} from "../types";
import {
TEXT_ALIGN_LABELS,
} from "../types";
import { migrateConfig } from "./PopDashboardComponent";
import {
fetchTableColumns,
fetchTableList,
type ColumnInfo,
type TableInfo,
} from "./utils/dataFetcher";
import { validateExpression } from "./utils/formula";
// ===== Props =====
interface ConfigPanelProps {
config: PopDashboardConfig | undefined;
onUpdate: (config: PopDashboardConfig) => void;
/** 페이지 미리보기 요청 (-1이면 해제) */
onPreviewPage?: (pageIndex: number) => void;
/** 현재 미리보기 중인 페이지 인덱스 */
previewPageIndex?: number;
}
// ===== 기본값 =====
const DEFAULT_CONFIG: PopDashboardConfig = {
items: [],
pages: [],
displayMode: "arrows",
autoSlideInterval: 5,
autoSlideResumeDelay: 3,
showIndicator: true,
gap: 8,
};
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": "자동 슬라이드",
scroll: "스크롤",
};
const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
"kpi-card": "KPI 카드",
chart: "차트",
gauge: "게이지",
"stat-card": "통계 카드",
};
const JOIN_TYPE_LABELS: Record<JoinType, string> = {
inner: "INNER JOIN",
left: "LEFT JOIN",
right: "RIGHT JOIN",
};
// ===== 집계 함수 유효성 검증 유틸 =====
// 아이템 타입별 사용 가능한 집계 함수
const SUBTYPE_AGGREGATION_MAP: Record<DashboardSubType, AggregationType[]> = {
"kpi-card": ["count", "sum", "avg", "min", "max"],
chart: ["count", "sum", "avg", "min", "max"],
gauge: ["count", "sum", "avg", "min", "max"],
"stat-card": ["count"],
};
// 집계 함수 라벨
const AGGREGATION_LABELS: Record<AggregationType, string> = {
count: "건수 (COUNT)",
sum: "합계 (SUM)",
avg: "평균 (AVG)",
min: "최소 (MIN)",
max: "최대 (MAX)",
};
// 숫자 전용 집계 함수 (숫자 컬럼에만 사용 가능)
const NUMERIC_ONLY_AGGREGATIONS: AggregationType[] = ["sum", "avg"];
// PostgreSQL 숫자 타입 판별용 패턴
const NUMERIC_TYPE_PATTERNS = [
"int", "integer", "bigint", "smallint",
"numeric", "decimal", "real", "double",
"float", "serial", "bigserial", "smallserial",
"money", "number",
];
/** 컬럼이 숫자 타입인지 판별 */
function isNumericColumn(col: ColumnInfo): boolean {
const t = (col.type || "").toLowerCase();
const u = (col.udtName || "").toLowerCase();
return NUMERIC_TYPE_PATTERNS.some(
(pattern) => t.includes(pattern) || u.includes(pattern)
);
}
/** 현재 집계 함수가 숫자 전용(sum/avg)인지 판별 */
function isNumericOnlyAggregation(aggType: string | undefined): boolean {
return !!aggType && NUMERIC_ONLY_AGGREGATIONS.includes(aggType as AggregationType);
}
const FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
"=": "같음 (=)",
"!=": "다름 (!=)",
">": "초과 (>)",
">=": "이상 (>=)",
"<": "미만 (<)",
"<=": "이하 (<=)",
like: "포함 (LIKE)",
in: "목록 (IN)",
between: "범위 (BETWEEN)",
};
// ===== 데이터 소스 편집기 =====
function DataSourceEditor({
dataSource,
onChange,
subType,
}: {
dataSource: DataSourceConfig;
onChange: (ds: DataSourceConfig) => void;
subType?: DashboardSubType;
}) {
// 테이블 목록 (Combobox용)
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableOpen, setTableOpen] = useState(false);
// 컬럼 목록 (집계 대상 컬럼용)
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
const [columnOpen, setColumnOpen] = useState(false);
// 그룹핑 컬럼 (차트 X축용)
const [groupByOpen, setGroupByOpen] = useState(false);
// 마운트 시 테이블 목록 로드
useEffect(() => {
setLoadingTables(true);
fetchTableList()
.then(setTables)
.finally(() => setLoadingTables(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">
{/* 테이블 선택 (검색 가능한 Combobox) */}
<div>
<Label className="text-xs"></Label>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
className="h-8 w-full justify-between text-xs"
>
{dataSource.tableName
? (tables.find((t) => t.tableName === dataSource.tableName)
?.displayName ?? dataSource.tableName)
: loadingTables
? "로딩..."
: "테이블 선택"}
<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-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
const newVal =
table.tableName === dataSource.tableName
? ""
: table.tableName;
onChange({ ...dataSource, tableName: newVal });
setTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.tableName === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{table.displayName || table.tableName}
</span>
{table.displayName &&
table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 집계 함수 + 대상 컬럼 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={dataSource.aggregation?.type ?? ""}
onValueChange={(val) => {
// STEP 4: 숫자 전용 집계로 변경 시, 현재 컬럼이 숫자가 아니면 초기화
let currentColumn = dataSource.aggregation?.column ?? "";
if (val && isNumericOnlyAggregation(val) && currentColumn) {
const selectedCol = columns.find((c) => c.name === currentColumn);
if (selectedCol && !isNumericColumn(selectedCol)) {
currentColumn = "";
}
}
onChange({
...dataSource,
aggregation: val
? {
type: val as NonNullable<
DataSourceConfig["aggregation"]
>["type"],
column: currentColumn,
groupBy: dataSource.aggregation?.groupBy,
}
: undefined,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
{(subType
? SUBTYPE_AGGREGATION_MAP[subType]
: (Object.keys(AGGREGATION_LABELS) as AggregationType[])
).map((aggType) => (
<SelectItem key={aggType} value={aggType}>
{AGGREGATION_LABELS[aggType]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{dataSource.aggregation && (
<div>
<Label className="text-xs"> </Label>
<Popover open={columnOpen} onOpenChange={setColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={columnOpen}
disabled={loadingCols}
className="h-8 w-full justify-between text-xs"
>
{loadingCols
? "로딩..."
: dataSource.aggregation.column
? columns.find(
(c) => c.name === dataSource.aggregation!.column
)
? `${dataSource.aggregation.column} (${columns.find((c) => c.name === dataSource.aggregation!.column)?.type})`
: dataSource.aggregation.column
: "선택"}
<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-2 text-center text-xs">
{isNumericOnlyAggregation(dataSource.aggregation?.type)
? "숫자 타입 컬럼이 없습니다."
: "컬럼을 찾을 수 없습니다."}
</CommandEmpty>
<CommandGroup>
{(isNumericOnlyAggregation(dataSource.aggregation?.type)
? columns.filter(isNumericColumn)
: columns
).map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.type}`}
onSelect={() => {
onChange({
...dataSource,
aggregation: {
...dataSource.aggregation!,
column: col.name,
},
});
setColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.aggregation?.column === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">
({col.type})
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* 그룹핑 (차트 X축 분류) */}
{dataSource.aggregation && (
<div>
<Label className="text-xs"> (X축)</Label>
<Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={groupByOpen}
disabled={loadingCols}
className="h-8 w-full justify-between text-xs"
>
{dataSource.aggregation.groupBy?.length
? dataSource.aggregation.groupBy.join(", ")
: "없음 (단일 값)"}
<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-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.name}
value={`groupby-${col.name}`}
onSelect={() => {
const current = dataSource.aggregation?.groupBy ?? [];
const isSelected = current.includes(col.name);
const newGroupBy = isSelected
? current.filter((g) => g !== col.name)
: [...current, col.name];
onChange({
...dataSource,
aggregation: {
...dataSource.aggregation!,
groupBy: newGroupBy.length > 0 ? newGroupBy : undefined,
},
});
setGroupByOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.aggregation?.groupBy?.includes(col.name)
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.type})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-0.5 text-[10px] text-muted-foreground">
X축
</p>
{subType === "chart" && !dataSource.aggregation?.groupBy?.length && (
<p className="mt-0.5 text-[10px] text-destructive">
(X축)
</p>
)}
</div>
)}
{/* 자동 새로고침 (Switch + 주기 입력) */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={(dataSource.refreshInterval ?? 0) > 0}
onCheckedChange={(checked) =>
onChange({
...dataSource,
refreshInterval: checked ? 30 : 0,
})
}
/>
</div>
{(dataSource.refreshInterval ?? 0) > 0 && (
<div>
<Label className="text-[10px] text-muted-foreground">
()
</Label>
<Input
type="number"
value={dataSource.refreshInterval ?? 30}
onChange={(e) =>
onChange({
...dataSource,
refreshInterval: Math.max(
5,
parseInt(e.target.value) || 30
),
})
}
className="h-7 text-xs"
min={5}
/>
</div>
)}
</div>
{/* 조인 설정 */}
<JoinEditor
joins={dataSource.joins ?? []}
mainTable={dataSource.tableName}
onChange={(joins) => onChange({ ...dataSource, joins })}
/>
{/* 필터 조건 */}
<FilterEditor
filters={dataSource.filters ?? []}
tableName={dataSource.tableName}
onChange={(filters) => onChange({ ...dataSource, filters })}
/>
</div>
);
}
// ===== 조인 편집기 =====
function JoinEditor({
joins,
mainTable,
onChange,
}: {
joins: JoinConfig[];
mainTable: string;
onChange: (joins: JoinConfig[]) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
// 테이블 목록 로드
useEffect(() => {
fetchTableList().then(setTables);
}, []);
const addJoin = () => {
onChange([
...joins,
{
targetTable: "",
joinType: "left",
on: { sourceColumn: "", targetColumn: "" },
},
]);
};
const updateJoin = (index: number, partial: Partial<JoinConfig>) => {
const newJoins = [...joins];
newJoins[index] = { ...newJoins[index], ...partial };
onChange(newJoins);
};
const removeJoin = (index: number) => {
onChange(joins.filter((_, i) => i !== index));
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={addJoin}
disabled={!mainTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{!mainTable && joins.length === 0 && (
<p className="text-[10px] text-muted-foreground">
</p>
)}
{joins.map((join, index) => (
<JoinRow
key={index}
join={join}
mainTable={mainTable}
tables={tables}
onUpdate={(partial) => updateJoin(index, partial)}
onRemove={() => removeJoin(index)}
/>
))}
</div>
);
}
function JoinRow({
join,
mainTable,
tables,
onUpdate,
onRemove,
}: {
join: JoinConfig;
mainTable: string;
tables: TableInfo[];
onUpdate: (partial: Partial<JoinConfig>) => void;
onRemove: () => void;
}) {
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [targetTableOpen, setTargetTableOpen] = useState(false);
// 메인 테이블 컬럼 로드
useEffect(() => {
if (!mainTable) return;
fetchTableColumns(mainTable).then(setSourceColumns);
}, [mainTable]);
// 조인 대상 테이블 컬럼 로드
useEffect(() => {
if (!join.targetTable) return;
fetchTableColumns(join.targetTable).then(setTargetColumns);
}, [join.targetTable]);
return (
<div className="space-y-1.5 rounded border p-2">
<div className="flex items-center gap-1">
{/* 조인 타입 */}
<Select
value={join.joinType}
onValueChange={(val) => onUpdate({ joinType: val as JoinType })}
>
<SelectTrigger className="h-7 w-28 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(JOIN_TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 조인 대상 테이블 (Combobox) */}
<Popover open={targetTableOpen} onOpenChange={setTargetTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 flex-1 justify-between text-[10px]"
>
{join.targetTable
? (tables.find((t) => t.tableName === join.targetTable)
?.displayName ?? join.targetTable)
: "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{tables
.filter((t) => t.tableName !== mainTable)
.map((t) => (
<CommandItem
key={t.tableName}
value={t.tableName}
onSelect={() => {
onUpdate({ targetTable: t.tableName });
setTargetTableOpen(false);
}}
className="text-xs"
>
{t.displayName || t.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={onRemove}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 조인 조건 (ON 절) */}
{join.targetTable && (
<div className="flex items-center gap-1 text-[10px]">
<span className="shrink-0 text-muted-foreground">ON</span>
<Select
value={join.on.sourceColumn}
onValueChange={(val) =>
onUpdate({ on: { ...join.on, sourceColumn: val } })
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder={`${mainTable} 컬럼`} />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((c) => (
<SelectItem key={c.name} value={c.name} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-muted-foreground">=</span>
<Select
value={join.on.targetColumn}
onValueChange={(val) =>
onUpdate({ on: { ...join.on, targetColumn: val } })
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder={`${join.targetTable} 컬럼`} />
</SelectTrigger>
<SelectContent>
{targetColumns.map((c) => (
<SelectItem key={c.name} value={c.name} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
);
}
// ===== 필터 편집기 =====
function FilterEditor({
filters,
tableName,
onChange,
}: {
filters: DataSourceFilter[];
tableName: string;
onChange: (filters: DataSourceFilter[]) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
useEffect(() => {
if (!tableName) return;
fetchTableColumns(tableName).then(setColumns);
}, [tableName]);
const addFilter = () => {
onChange([...filters, { column: "", operator: "=", value: "" }]);
};
const updateFilter = (
index: number,
partial: Partial<DataSourceFilter>
) => {
const newFilters = [...filters];
newFilters[index] = { ...newFilters[index], ...partial };
// operator 변경 시 value 초기화
if (partial.operator) {
if (partial.operator === "between") {
newFilters[index].value = ["", ""];
} else if (partial.operator === "in") {
newFilters[index].value = [];
} else if (
typeof newFilters[index].value !== "string" &&
typeof newFilters[index].value !== "number"
) {
newFilters[index].value = "";
}
}
onChange(newFilters);
};
const removeFilter = (index: number) => {
onChange(filters.filter((_, i) => i !== index));
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={addFilter}
disabled={!tableName}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{filters.map((filter, index) => (
<div key={index} className="flex items-start gap-1">
{/* 컬럼 선택 */}
<Select
value={filter.column}
onValueChange={(val) => updateFilter(index, { column: val })}
>
<SelectTrigger className="h-7 w-24 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c.name} value={c.name} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 연산자 */}
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { operator: val as FilterOperator })
}
>
<SelectTrigger className="h-7 w-20 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILTER_OPERATOR_LABELS).map(([op, label]) => (
<SelectItem key={op} value={op} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 값 입력 (연산자에 따라 다른 UI) */}
<div className="flex-1">
{filter.operator === "between" ? (
<div className="flex gap-1">
<Input
value={
Array.isArray(filter.value)
? (filter.value[0] ?? "")
: ""
}
onChange={(e) => {
const arr = Array.isArray(filter.value)
? [...filter.value]
: ["", ""];
arr[0] = e.target.value;
updateFilter(index, { value: arr });
}}
placeholder="시작"
className="h-7 text-[10px]"
/>
<Input
value={
Array.isArray(filter.value)
? (filter.value[1] ?? "")
: ""
}
onChange={(e) => {
const arr = Array.isArray(filter.value)
? [...filter.value]
: ["", ""];
arr[1] = e.target.value;
updateFilter(index, { value: arr });
}}
placeholder="끝"
className="h-7 text-[10px]"
/>
</div>
) : filter.operator === "in" ? (
<Input
value={
Array.isArray(filter.value)
? filter.value.join(", ")
: String(filter.value ?? "")
}
onChange={(e) => {
const vals = e.target.value
.split(",")
.map((v) => v.trim())
.filter(Boolean);
updateFilter(index, { value: vals });
}}
placeholder="값1, 값2, 값3"
className="h-7 text-[10px]"
/>
) : (
<Input
value={String(filter.value ?? "")}
onChange={(e) =>
updateFilter(index, { value: e.target.value })
}
placeholder="값"
className="h-7 text-[10px]"
/>
)}
</div>
{/* 삭제 */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => removeFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</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);
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 shrink-0 text-muted-foreground" />
<Input
value={item.label}
onChange={(e) => onUpdate({ ...item, label: e.target.value })}
placeholder={`아이템 ${index + 1}`}
className="h-6 min-w-0 flex-1 border-0 bg-transparent px-1 text-xs font-medium shadow-none focus-visible:ring-1"
/>
<span className="shrink-0 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>
<Select
value={item.subType}
onValueChange={(val) => {
const newSubType = val as DashboardSubType;
const allowedAggs = SUBTYPE_AGGREGATION_MAP[newSubType];
const currentAggType = item.dataSource.aggregation?.type;
// STEP 7.5: subType 변경 시, 현재 집계 함수가 새 타입에서 허용되지 않으면 자동 전환
let newDataSource = item.dataSource;
if (currentAggType && !allowedAggs.includes(currentAggType)) {
const fallbackAgg = allowedAggs[0]; // 허용되는 첫 번째 집계 함수로 전환
newDataSource = {
...item.dataSource,
aggregation: item.dataSource.aggregation
? {
...item.dataSource.aggregation,
type: fallbackAgg,
// count로 전환되면 컬럼 의미 없으므로 초기화
column: fallbackAgg === "count" ? "" : item.dataSource.aggregation.column,
}
: undefined,
};
}
onUpdate({ ...item, subType: newSubType, dataSource: newDataSource });
}}
>
<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 })}
subType={item.subType}
/>
)}
{/* 요소별 보이기/숨기기 */}
<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 className="space-y-2">
<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>
{/* X축/Y축 자동 안내 */}
<p className="text-[10px] text-muted-foreground">
X축: 그룹핑(X축) / Y축: 집계 (value)
</p>
</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: {
...item.gaugeConfig,
min: parseInt(e.target.value) || 0,
max: item.gaugeConfig?.max ?? 100,
},
})
}
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: {
...item.gaugeConfig,
min: item.gaugeConfig?.min ?? 0,
max: parseInt(e.target.value) || 100,
},
})
}
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={item.gaugeConfig?.target ?? ""}
onChange={(e) =>
onUpdate({
...item,
gaugeConfig: {
...item.gaugeConfig,
min: item.gaugeConfig?.min ?? 0,
max: item.gaugeConfig?.max ?? 100,
target: parseInt(e.target.value) || undefined,
},
})
}
className="h-8 text-xs"
/>
</div>
</div>
)}
{/* 통계 카드 카테고리 설정 */}
{item.subType === "stat-card" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentCats = item.statConfig?.categories ?? [];
onUpdate({
...item,
statConfig: {
...item.statConfig,
categories: [
...currentCats,
{
label: `카테고리 ${currentCats.length + 1}`,
filter: { column: "", operator: "=", value: "" },
},
],
},
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
<div key={catIdx} className="space-y-1 rounded border p-2">
<div className="flex items-center gap-1">
<Input
value={cat.label}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = { ...cat, label: e.target.value };
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="라벨 (예: 수주)"
className="h-6 flex-1 text-xs"
/>
<Input
value={cat.color ?? ""}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = { ...cat, color: e.target.value || undefined };
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="#색상코드"
className="h-6 w-20 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => {
const newCats = (item.statConfig?.categories ?? []).filter(
(_, i) => i !== catIdx
);
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
<div className="flex items-center gap-1 text-[10px]">
<Input
value={cat.filter.column}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, column: e.target.value },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="컬럼"
className="h-6 w-20 text-[10px]"
/>
<Select
value={cat.filter.operator}
onValueChange={(val) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, operator: val as FilterOperator },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<SelectTrigger className="h-6 w-16 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=" className="text-xs">= </SelectItem>
<SelectItem value="!=" className="text-xs">!= </SelectItem>
<SelectItem value="like" className="text-xs">LIKE</SelectItem>
</SelectContent>
</Select>
<Input
value={String(cat.filter.value ?? "")}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, value: e.target.value },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
</div>
</div>
))}
{(item.statConfig?.categories ?? []).length === 0 && (
<p className="text-[10px] text-muted-foreground">
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
// ===== 그리드 레이아웃 편집기 =====
/** 기본 셀 그리드 생성 헬퍼 */
function generateDefaultCells(
cols: number,
rows: number
): DashboardCell[] {
const cells: DashboardCell[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
cells.push({
id: `cell-${r}-${c}`,
gridColumn: `${c + 1} / ${c + 2}`,
gridRow: `${r + 1} / ${r + 2}`,
itemId: null,
});
}
}
return cells;
}
// =====================================================
// 아이템 스타일 에디터 (접기/펼치기 지원)
// =====================================================
function ItemStyleEditor({
item,
onUpdate,
}: {
item: DashboardItem;
onUpdate: (updatedItem: DashboardItem) => void;
}) {
const [expanded, setExpanded] = useState(false);
const updateStyle = (partial: Partial<ItemStyleConfig>) => {
const updatedItem = {
...item,
itemStyle: { ...item.itemStyle, ...partial },
};
onUpdate(updatedItem);
};
return (
<div className="rounded border">
{/* 헤더 - 클릭으로 접기/펼치기 */}
<button
type="button"
className="flex w-full items-center justify-between px-2 py-1.5 text-left hover:bg-muted/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<span className="text-[10px] font-medium truncate">
{item.label || item.id}
</span>
{expanded ? (
<ChevronUp className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
</button>
{/* 내용 - 접기/펼치기 */}
{expanded && (
<div className="space-y-2 border-t px-2 pb-2 pt-1.5">
{/* 라벨 정렬 */}
<div>
<span className="text-[9px] text-muted-foreground">
</span>
<div className="mt-0.5 flex gap-1">
{(["left", "center", "right"] as const).map((align) => (
<Button
key={align}
variant={
item.itemStyle?.labelAlign === align ||
(!item.itemStyle?.labelAlign && align === "center")
? "default"
: "outline"
}
size="sm"
className="h-6 flex-1 text-[10px] px-1"
onClick={() => updateStyle({ labelAlign: align })}
>
{TEXT_ALIGN_LABELS[align]}
</Button>
))}
</div>
</div>
</div>
)}
</div>
);
}
function GridLayoutEditor({
cells,
gridColumns,
gridRows,
items,
onChange,
onUpdateItem,
}: {
cells: DashboardCell[];
gridColumns: number;
gridRows: number;
items: DashboardItem[];
onChange: (cells: DashboardCell[], cols: number, rows: number) => void;
/** 아이템 스타일 업데이트 콜백 */
onUpdateItem?: (updatedItem: DashboardItem) => void;
}) {
const ensuredCells =
cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows);
return (
<div className="space-y-3">
{/* 행/열 조절 버튼 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Label className="text-xs"></Label>
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={() => {
if (gridColumns > 1) {
const newCells = ensuredCells.filter((c) => {
const col = parseInt(c.gridColumn.split(" / ")[0]);
return col <= gridColumns - 1;
});
onChange(newCells, gridColumns - 1, gridRows);
}
}}
disabled={gridColumns <= 1}
>
<span className="text-xs">-</span>
</Button>
<span className="w-6 text-center text-xs font-medium">
{gridColumns}
</span>
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={() => {
if (gridColumns < 6) {
const newCells = [...ensuredCells];
for (let r = 0; r < gridRows; r++) {
newCells.push({
id: `cell-${r}-${gridColumns}`,
gridColumn: `${gridColumns + 1} / ${gridColumns + 2}`,
gridRow: `${r + 1} / ${r + 2}`,
itemId: null,
});
}
onChange(newCells, gridColumns + 1, gridRows);
}
}}
disabled={gridColumns >= 6}
>
<span className="text-xs">+</span>
</Button>
</div>
<div className="flex items-center gap-1">
<Label className="text-xs"></Label>
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={() => {
if (gridRows > 1) {
const newCells = ensuredCells.filter((c) => {
const row = parseInt(c.gridRow.split(" / ")[0]);
return row <= gridRows - 1;
});
onChange(newCells, gridColumns, gridRows - 1);
}
}}
disabled={gridRows <= 1}
>
<span className="text-xs">-</span>
</Button>
<span className="w-6 text-center text-xs font-medium">
{gridRows}
</span>
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={() => {
if (gridRows < 6) {
const newCells = [...ensuredCells];
for (let c = 0; c < gridColumns; c++) {
newCells.push({
id: `cell-${gridRows}-${c}`,
gridColumn: `${c + 1} / ${c + 2}`,
gridRow: `${gridRows + 1} / ${gridRows + 2}`,
itemId: null,
});
}
onChange(newCells, gridColumns, gridRows + 1);
}
}}
disabled={gridRows >= 6}
>
<span className="text-xs">+</span>
</Button>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 text-[10px]"
onClick={() =>
onChange(
generateDefaultCells(gridColumns, gridRows),
gridColumns,
gridRows
)
}
>
</Button>
</div>
{/* 시각적 그리드 프리뷰 + 아이템 배정 */}
<div
className="gap-1 rounded border p-2"
style={{
display: "grid",
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gridTemplateRows: `repeat(${gridRows}, 48px)`,
}}
>
{ensuredCells.map((cell) => (
<div
key={cell.id}
className={cn(
"flex items-center justify-center rounded border border-dashed p-0.5 transition-colors",
cell.itemId
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
)}
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 bg-transparent p-0 text-[10px] shadow-none">
<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>
<p className="text-[10px] text-muted-foreground">
. +/- /
/ .
</p>
{/* 배정된 아이템별 스타일 설정 */}
{onUpdateItem && (() => {
const assignedItemIds = ensuredCells
.map((c) => c.itemId)
.filter((id): id is string => !!id);
const uniqueIds = [...new Set(assignedItemIds)];
const assignedItems = uniqueIds
.map((id) => items.find((i) => i.id === id))
.filter((i): i is DashboardItem => !!i);
if (assignedItems.length === 0) return null;
return (
<div className="space-y-1 border-t pt-2">
<span className="text-[10px] font-medium text-muted-foreground">
</span>
{assignedItems.map((item) => (
<ItemStyleEditor
key={item.id}
item={item}
onUpdate={onUpdateItem}
/>
))}
</div>
);
})()}
</div>
);
}
// ===== 페이지 편집기 =====
function PageEditor({
page,
pageIndex,
items,
onChange,
onDelete,
onPreview,
isPreviewing,
onUpdateItem,
}: {
page: DashboardPage;
pageIndex: number;
items: DashboardItem[];
onChange: (updatedPage: DashboardPage) => void;
onDelete: () => void;
onPreview?: () => void;
isPreviewing?: boolean;
onUpdateItem?: (updatedItem: DashboardItem) => void;
}) {
const [expanded, setExpanded] = useState(true);
return (
<div className="rounded-md border p-2">
{/* 헤더 */}
<div className="flex items-center gap-1">
<span className="flex-1 truncate text-xs font-medium">
{page.label || `페이지 ${pageIndex + 1}`}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{page.gridColumns}x{page.gridRows}
</span>
<Button
variant={isPreviewing ? "default" : "ghost"}
size="icon"
className="h-6 w-6"
onClick={() => onPreview?.()}
title="이 페이지 미리보기"
>
<Eye className="h-3 w-3" />
</Button>
<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-2">
{/* 라벨 */}
<div>
<Label className="text-xs"></Label>
<Input
value={page.label ?? ""}
onChange={(e) =>
onChange({ ...page, label: e.target.value })
}
placeholder={`페이지 ${pageIndex + 1}`}
className="h-7 text-xs"
/>
</div>
{/* GridLayoutEditor 재사용 */}
<GridLayoutEditor
cells={page.gridCells}
gridColumns={page.gridColumns}
gridRows={page.gridRows}
items={items}
onChange={(cells, cols, rows) =>
onChange({
...page,
gridCells: cells,
gridColumns: cols,
gridRows: rows,
})
}
onUpdateItem={onUpdateItem}
/>
</div>
)}
</div>
);
}
// ===== 메인 설정 패널 =====
export function PopDashboardConfigPanel(props: ConfigPanelProps) {
const { config, onUpdate: onChange } = props;
// config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장
const merged = { ...DEFAULT_CONFIG, ...(config || {}) };
// 마이그레이션: 기존 useGridLayout/grid* -> pages 기반으로 변환
const cfg = migrateConfig(
merged as unknown as Record<string, unknown>
) as PopDashboardConfig;
const [activeTab, setActiveTab] = useState<"basic" | "items" | "pages">(
"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]
);
// 아이템 삭제 (모든 페이지의 셀 배정도 해제)
const deleteItem = useCallback(
(index: number) => {
const deletedId = cfg.items[index].id;
const newItems = cfg.items.filter((_, i) => i !== index);
const newPages = cfg.pages?.map((page) => ({
...page,
gridCells: page.gridCells.map((cell) =>
cell.itemId === deletedId ? { ...cell, itemId: null } : cell
),
}));
updateConfig({ items: newItems, pages: newPages });
},
[cfg.items, cfg.pages, 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", "아이템"],
["pages", "페이지"],
] 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>
)}
{/* ===== 페이지 탭 ===== */}
{activeTab === "pages" && (
<div className="space-y-3">
{/* 페이지 목록 */}
{(cfg.pages ?? []).map((page, pageIdx) => (
<PageEditor
key={page.id}
page={page}
pageIndex={pageIdx}
items={cfg.items}
onChange={(updatedPage) => {
const newPages = [...(cfg.pages ?? [])];
newPages[pageIdx] = updatedPage;
updateConfig({ pages: newPages });
}}
onDelete={() => {
const newPages = (cfg.pages ?? []).filter(
(_, i) => i !== pageIdx
);
updateConfig({ pages: newPages });
}}
onPreview={() => {
if (props.onPreviewPage) {
// 같은 페이지를 다시 누르면 미리보기 해제
props.onPreviewPage(props.previewPageIndex === pageIdx ? -1 : pageIdx);
}
}}
isPreviewing={props.previewPageIndex === pageIdx}
onUpdateItem={(updatedItem) => {
const newItems = cfg.items.map((i) =>
i.id === updatedItem.id ? updatedItem : i
);
updateConfig({ items: newItems });
// 스타일 변경 시 자동으로 해당 페이지 미리보기 활성화
if (props.onPreviewPage && props.previewPageIndex !== pageIdx) {
props.onPreviewPage(pageIdx);
}
}}
/>
))}
{/* 페이지 추가 버튼 */}
<Button
variant="outline"
className="h-9 w-full text-xs"
onClick={() => {
const newPage: DashboardPage = {
id: `page-${Date.now()}`,
label: `페이지 ${(cfg.pages?.length ?? 0) + 1}`,
gridColumns: 2,
gridRows: 2,
gridCells: generateDefaultCells(2, 2),
};
updateConfig({ pages: [...(cfg.pages ?? []), newPage] });
}}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
{(cfg.pages?.length ?? 0) === 0 && (
<p className="text-center text-[10px] text-muted-foreground">
.
<br />
.
</p>
)}
</div>
)}
</div>
);
}