2106 lines
66 KiB
TypeScript
2106 lines
66 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* pop-dashboard 설정 패널 (디자이너용)
|
|
*
|
|
* 3개 탭:
|
|
* [기본 설정] - 표시 모드, 간격, 인디케이터
|
|
* [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정
|
|
* [페이지] - 페이지(슬라이드) 추가/삭제, 각 페이지 독립 그리드 레이아웃
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
GripVertical,
|
|
Check,
|
|
ChevronsUpDown,
|
|
} 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,
|
|
} 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;
|
|
}
|
|
|
|
// ===== 기본값 =====
|
|
|
|
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 FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
|
|
"=": "같음 (=)",
|
|
"!=": "다름 (!=)",
|
|
">": "초과 (>)",
|
|
">=": "이상 (>=)",
|
|
"<": "미만 (<)",
|
|
"<=": "이하 (<=)",
|
|
like: "포함 (LIKE)",
|
|
in: "목록 (IN)",
|
|
between: "범위 (BETWEEN)",
|
|
};
|
|
|
|
// ===== 데이터 소스 편집기 =====
|
|
|
|
function DataSourceEditor({
|
|
dataSource,
|
|
onChange,
|
|
}: {
|
|
dataSource: DataSourceConfig;
|
|
onChange: (ds: DataSourceConfig) => void;
|
|
}) {
|
|
// 테이블 목록 (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) =>
|
|
onChange({
|
|
...dataSource,
|
|
aggregation: val
|
|
? {
|
|
type: val as 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>
|
|
<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">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{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>
|
|
</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) =>
|
|
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 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: {
|
|
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>
|
|
)}
|
|
|
|
{/* 통계 카드 카테고리 설정 */}
|
|
{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 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 : 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 페이지 편집기 =====
|
|
|
|
function PageEditor({
|
|
page,
|
|
pageIndex,
|
|
items,
|
|
onChange,
|
|
onDelete,
|
|
}: {
|
|
page: DashboardPage;
|
|
pageIndex: number;
|
|
items: DashboardItem[];
|
|
onChange: (updatedPage: DashboardPage) => void;
|
|
onDelete: () => 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="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,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 메인 설정 패널 =====
|
|
|
|
export function PopDashboardConfigPanel({
|
|
config,
|
|
onUpdate: onChange,
|
|
}: ConfigPanelProps) {
|
|
// 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 });
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* 페이지 추가 버튼 */}
|
|
<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>
|
|
);
|
|
}
|