619 lines
28 KiB
TypeScript
619 lines
28 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2 피벗 그리드 설정 패널
|
|
* 토스식 단계별 UX: 테이블 선택(Combobox) -> 필드 배치(영역 드롭존) -> 고급 설정(Collapsible)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import {
|
|
Rows, Columns, Calculator, X, Plus, GripVertical,
|
|
Check, ChevronsUpDown, ChevronDown, ChevronUp, Settings, Database, Info,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import type {
|
|
PivotGridComponentConfig,
|
|
PivotFieldConfig,
|
|
PivotAreaType,
|
|
AggregationType,
|
|
FieldDataType,
|
|
} from "@/lib/registry/components/v2-pivot-grid/types";
|
|
|
|
interface TableInfo {
|
|
tableName: string;
|
|
displayName: string;
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
column_name: string;
|
|
data_type: string;
|
|
column_comment?: string;
|
|
}
|
|
|
|
interface V2PivotGridConfigPanelProps {
|
|
config: PivotGridComponentConfig;
|
|
onChange: (config: PivotGridComponentConfig) => void;
|
|
}
|
|
|
|
function mapDbTypeToFieldType(dbType: string): FieldDataType {
|
|
const type = dbType.toLowerCase();
|
|
if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) return "number";
|
|
if (type.includes("date") || type.includes("time") || type.includes("timestamp")) return "date";
|
|
if (type.includes("bool")) return "boolean";
|
|
return "string";
|
|
}
|
|
|
|
// ─── 영역 드롭존 (토스식) ───
|
|
|
|
interface AreaDropZoneProps {
|
|
area: PivotAreaType;
|
|
label: string;
|
|
description: string;
|
|
icon: React.ReactNode;
|
|
fields: PivotFieldConfig[];
|
|
columns: ColumnInfo[];
|
|
onAddField: (column: ColumnInfo) => void;
|
|
onRemoveField: (index: number) => void;
|
|
onUpdateField: (index: number, updates: Partial<PivotFieldConfig>) => void;
|
|
borderClass: string;
|
|
}
|
|
|
|
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
|
|
area, label, description, icon, fields, columns,
|
|
onAddField, onRemoveField, onUpdateField, borderClass,
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
const availableColumns = columns.filter((col) => !fields.some((f) => f.field === col.column_name));
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border-2 p-3", borderClass)}>
|
|
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
|
|
<div className="flex items-center gap-2">
|
|
{icon}
|
|
<span className="text-sm font-medium">{label}</span>
|
|
<Badge variant="secondary" className="text-xs">{fields.length}</Badge>
|
|
</div>
|
|
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-1">{description}</p>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-3 space-y-2">
|
|
{fields.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{fields.map((field, idx) => (
|
|
<div key={`${field.field}-${idx}`} className="flex items-center gap-2 rounded-md border bg-background px-2 py-1.5">
|
|
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
|
<span className="flex-1 truncate text-xs font-medium">{field.caption || field.field}</span>
|
|
{area === "data" && (
|
|
<Select
|
|
value={field.summaryType || "sum"}
|
|
onValueChange={(v) => onUpdateField(idx, { summaryType: v as AggregationType })}
|
|
>
|
|
<SelectTrigger className="h-6 w-20 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sum">합계</SelectItem>
|
|
<SelectItem value="count">개수</SelectItem>
|
|
<SelectItem value="avg">평균</SelectItem>
|
|
<SelectItem value="min">최소</SelectItem>
|
|
<SelectItem value="max">최대</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onRemoveField(idx)}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-dashed py-2 text-center text-xs text-muted-foreground">
|
|
아래에서 컬럼을 선택하세요
|
|
</div>
|
|
)}
|
|
|
|
{availableColumns.length > 0 && (
|
|
<Select onValueChange={(v) => { const col = columns.find((c) => c.column_name === v); if (col) onAddField(col); }}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
<span>컬럼 추가</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
<div className="flex items-center gap-2">
|
|
<span>{col.column_comment || col.column_name}</span>
|
|
<span className="text-xs text-muted-foreground">({mapDbTypeToFieldType(col.data_type)})</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 메인 패널 ───
|
|
|
|
export const V2PivotGridConfigPanel: React.FC<V2PivotGridConfigPanelProps> = ({ config, onChange }) => {
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [tableOpen, setTableOpen] = useState(false);
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const tableList = await tableTypeApi.getTables();
|
|
setTables(
|
|
tableList.map((t: any) => ({
|
|
tableName: t.tableName,
|
|
displayName: t.tableLabel || t.displayName || t.tableName,
|
|
}))
|
|
);
|
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const loadColumns = async () => {
|
|
if (!config.dataSource?.tableName) { setColumns([]); return; }
|
|
setLoadingColumns(true);
|
|
try {
|
|
const columnList = await tableTypeApi.getColumns(config.dataSource.tableName);
|
|
setColumns(
|
|
columnList.map((c: any) => ({
|
|
column_name: c.columnName || c.column_name,
|
|
data_type: c.dataType || c.data_type || "text",
|
|
column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name,
|
|
}))
|
|
);
|
|
} catch { /* ignore */ } finally { setLoadingColumns(false); }
|
|
};
|
|
loadColumns();
|
|
}, [config.dataSource?.tableName]);
|
|
|
|
const updateConfig = useCallback(
|
|
(updates: Partial<PivotGridComponentConfig>) => { onChange({ ...config, ...updates }); },
|
|
[config, onChange]
|
|
);
|
|
|
|
const handleAddField = (area: PivotAreaType, column: ColumnInfo) => {
|
|
const currentFields = config.fields || [];
|
|
const areaFields = currentFields.filter((f) => f.area === area);
|
|
const newField: PivotFieldConfig = {
|
|
field: column.column_name,
|
|
caption: column.column_comment || column.column_name,
|
|
area,
|
|
areaIndex: areaFields.length,
|
|
dataType: mapDbTypeToFieldType(column.data_type),
|
|
visible: true,
|
|
};
|
|
if (area === "data") newField.summaryType = "sum";
|
|
updateConfig({ fields: [...currentFields, newField] });
|
|
};
|
|
|
|
const handleRemoveField = (area: PivotAreaType, index: number) => {
|
|
const currentFields = config.fields || [];
|
|
const newFields = currentFields.filter((f) => !(f.area === area && f.areaIndex === index));
|
|
let idx = 0;
|
|
newFields.forEach((f) => { if (f.area === area) f.areaIndex = idx++; });
|
|
updateConfig({ fields: newFields });
|
|
};
|
|
|
|
const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial<PivotFieldConfig>) => {
|
|
const currentFields = config.fields || [];
|
|
const newFields = currentFields.map((f) =>
|
|
f.area === area && f.areaIndex === index ? { ...f, ...updates } : f
|
|
);
|
|
updateConfig({ fields: newFields });
|
|
};
|
|
|
|
const getFieldsByArea = (area: PivotAreaType) =>
|
|
(config.fields || []).filter((f) => f.area === area).sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
|
|
|
const selectedTable = tables.find((t) => t.tableName === config.dataSource?.tableName);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ─── 안내 ─── */}
|
|
<div className="rounded-lg border bg-primary/5 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="mt-0.5 h-4 w-4 text-primary" />
|
|
<div className="text-xs text-primary space-y-0.5">
|
|
<p className="font-medium">피벗 테이블 설정</p>
|
|
<ol className="list-inside list-decimal">
|
|
<li>테이블을 선택하세요</li>
|
|
<li>행/열/값 영역에 컬럼을 배치하세요</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── 1단계: 테이블 선택 ─── */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
<p className="text-sm font-medium">테이블 선택</p>
|
|
</div>
|
|
<Popover open={tableOpen} onOpenChange={setTableOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={tableOpen}
|
|
className="h-8 w-full justify-between text-xs"
|
|
disabled={loadingTables}
|
|
>
|
|
{loadingTables
|
|
? "로딩 중..."
|
|
: selectedTable
|
|
? selectedTable.displayName
|
|
: "테이블을 선택하세요"}
|
|
<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-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
|
{tables.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableName} ${table.displayName}`}
|
|
onSelect={() => {
|
|
updateConfig({
|
|
dataSource: { ...config.dataSource, type: "table", tableName: table.tableName },
|
|
fields: [],
|
|
});
|
|
setTableOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", config.dataSource?.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.displayName}</span>
|
|
{table.displayName !== table.tableName && (
|
|
<span className="text-[10px] text-muted-foreground">{table.tableName}</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<p className="text-[11px] text-muted-foreground">피벗 분석할 데이터가 있는 테이블을 선택해요</p>
|
|
</div>
|
|
|
|
{/* ─── 2단계: 필드 배치 ─── */}
|
|
{config.dataSource?.tableName && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium">
|
|
필드 배치
|
|
{loadingColumns && <span className="ml-2 text-xs text-muted-foreground">(컬럼 로딩 중...)</span>}
|
|
</p>
|
|
|
|
<div className="grid gap-3">
|
|
<AreaDropZone
|
|
area="row"
|
|
label="행 그룹"
|
|
description="세로로 그룹화할 항목 (예: 지역, 부서)"
|
|
icon={<Rows className="h-4 w-4 text-emerald-600" />}
|
|
fields={getFieldsByArea("row")}
|
|
columns={columns}
|
|
onAddField={(col) => handleAddField("row", col)}
|
|
onRemoveField={(idx) => handleRemoveField("row", idx)}
|
|
onUpdateField={(idx, u) => handleUpdateField("row", idx, u)}
|
|
borderClass="border-emerald-200 bg-emerald-50/50"
|
|
/>
|
|
<AreaDropZone
|
|
area="column"
|
|
label="열 그룹"
|
|
description="가로로 펼칠 항목 (예: 월, 분기)"
|
|
icon={<Columns className="h-4 w-4 text-primary" />}
|
|
fields={getFieldsByArea("column")}
|
|
columns={columns}
|
|
onAddField={(col) => handleAddField("column", col)}
|
|
onRemoveField={(idx) => handleRemoveField("column", idx)}
|
|
onUpdateField={(idx, u) => handleUpdateField("column", idx, u)}
|
|
borderClass="border-primary/20 bg-primary/5"
|
|
/>
|
|
<AreaDropZone
|
|
area="data"
|
|
label="값 (집계)"
|
|
description="합계, 평균 등 계산할 숫자 항목 (예: 매출)"
|
|
icon={<Calculator className="h-4 w-4 text-amber-600" />}
|
|
fields={getFieldsByArea("data")}
|
|
columns={columns}
|
|
onAddField={(col) => handleAddField("data", col)}
|
|
onRemoveField={(idx) => handleRemoveField("data", idx)}
|
|
onUpdateField={(idx, u) => handleUpdateField("data", idx, u)}
|
|
borderClass="border-amber-200 bg-amber-50/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 3단계: 고급 설정 (Collapsible) ─── */}
|
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">고급 설정</span>
|
|
</div>
|
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", advancedOpen && "rotate-180")} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
|
|
{/* 총계 설정 */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">총계 설정</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">행 총계</span>
|
|
<Switch
|
|
checked={config.totals?.showRowGrandTotals !== false}
|
|
onCheckedChange={(v) => updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">열 총계</span>
|
|
<Switch
|
|
checked={config.totals?.showColumnGrandTotals !== false}
|
|
onCheckedChange={(v) => updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">행 총계 위치</span>
|
|
<Select
|
|
value={config.totals?.rowGrandTotalPosition || "bottom"}
|
|
onValueChange={(v) => updateConfig({ totals: { ...config.totals, rowGrandTotalPosition: v as "top" | "bottom" } })}
|
|
>
|
|
<SelectTrigger className="h-6 w-16 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top">상단</SelectItem>
|
|
<SelectItem value="bottom">하단</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">열 총계 위치</span>
|
|
<Select
|
|
value={config.totals?.columnGrandTotalPosition || "right"}
|
|
onValueChange={(v) => updateConfig({ totals: { ...config.totals, columnGrandTotalPosition: v as "left" | "right" } })}
|
|
>
|
|
<SelectTrigger className="h-6 w-16 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="left">좌측</SelectItem>
|
|
<SelectItem value="right">우측</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">행 소계</span>
|
|
<Switch
|
|
checked={config.totals?.showRowTotals !== false}
|
|
onCheckedChange={(v) => updateConfig({ totals: { ...config.totals, showRowTotals: v } })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">열 소계</span>
|
|
<Switch
|
|
checked={config.totals?.showColumnTotals !== false}
|
|
onCheckedChange={(v) => updateConfig({ totals: { ...config.totals, showColumnTotals: v } })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 스타일 설정 */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">스타일</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">줄무늬</span>
|
|
<Switch
|
|
checked={config.style?.alternateRowColors !== false}
|
|
onCheckedChange={(v) => updateConfig({ style: { ...config.style, alternateRowColors: v } })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">셀 병합</span>
|
|
<Switch
|
|
checked={config.style?.mergeCells === true}
|
|
onCheckedChange={(v) => updateConfig({ style: { ...config.style, mergeCells: v } })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">CSV 내보내기</span>
|
|
<Switch
|
|
checked={config.exportConfig?.excel === true}
|
|
onCheckedChange={(v) => updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
|
|
<span className="text-xs">상태 저장</span>
|
|
<Switch
|
|
checked={(config as any).saveState === true}
|
|
onCheckedChange={(v) => updateConfig({ ...(config as any), saveState: v })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 크기 설정 */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">크기</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<span className="text-[11px] text-muted-foreground">높이</span>
|
|
<Input
|
|
value={config.height || ""}
|
|
onChange={(e) => updateConfig({ height: e.target.value })}
|
|
placeholder="400px"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span className="text-[11px] text-muted-foreground">최대 높이</span>
|
|
<Input
|
|
value={config.maxHeight || ""}
|
|
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
|
|
placeholder="600px"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 조건부 서식 */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">조건부 서식</p>
|
|
<div className="space-y-2">
|
|
{(config.style?.conditionalFormats || []).map((rule, index) => (
|
|
<div key={rule.id} className="flex items-center gap-2 rounded-md bg-muted/30 p-2">
|
|
<Select
|
|
value={rule.type}
|
|
onValueChange={(v) => {
|
|
const newFormats = [...(config.style?.conditionalFormats || [])];
|
|
newFormats[index] = { ...rule, type: v as any };
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 w-24 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="colorScale">색상 스케일</SelectItem>
|
|
<SelectItem value="dataBar">데이터 바</SelectItem>
|
|
<SelectItem value="iconSet">아이콘 세트</SelectItem>
|
|
<SelectItem value="cellValue">셀 값 조건</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{rule.type === "colorScale" && (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="color"
|
|
value={rule.colorScale?.minColor || "#ff0000"}
|
|
onChange={(e) => {
|
|
const newFormats = [...(config.style?.conditionalFormats || [])];
|
|
newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } };
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
className="h-6 w-6 cursor-pointer rounded"
|
|
title="최소값 색상"
|
|
/>
|
|
<span className="text-xs">→</span>
|
|
<input
|
|
type="color"
|
|
value={rule.colorScale?.maxColor || "#00ff00"}
|
|
onChange={(e) => {
|
|
const newFormats = [...(config.style?.conditionalFormats || [])];
|
|
newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } };
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
className="h-6 w-6 cursor-pointer rounded"
|
|
title="최대값 색상"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{rule.type === "dataBar" && (
|
|
<input
|
|
type="color"
|
|
value={rule.dataBar?.color || "#3b82f6"}
|
|
onChange={(e) => {
|
|
const newFormats = [...(config.style?.conditionalFormats || [])];
|
|
newFormats[index] = { ...rule, dataBar: { color: e.target.value } };
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
className="h-6 w-6 cursor-pointer rounded"
|
|
title="바 색상"
|
|
/>
|
|
)}
|
|
|
|
{rule.type === "iconSet" && (
|
|
<Select
|
|
value={rule.iconSet?.type || "traffic"}
|
|
onValueChange={(v) => {
|
|
const newFormats = [...(config.style?.conditionalFormats || [])];
|
|
newFormats[index] = { ...rule, iconSet: { type: v as any, thresholds: [33, 67] } };
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="arrows">화살표</SelectItem>
|
|
<SelectItem value="traffic">신호등</SelectItem>
|
|
<SelectItem value="rating">별점</SelectItem>
|
|
<SelectItem value="flags">깃발</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="ml-auto h-6 w-6"
|
|
onClick={() => {
|
|
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => {
|
|
const newFormats = [
|
|
...(config.style?.conditionalFormats || []),
|
|
{ id: `cf_${Date.now()}`, type: "colorScale" as const, colorScale: { minColor: "#ff0000", maxColor: "#00ff00" } },
|
|
];
|
|
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
|
}}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
조건부 서식 추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2PivotGridConfigPanel.displayName = "V2PivotGridConfigPanel";
|
|
|
|
export default V2PivotGridConfigPanel;
|