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