[agent-pipeline] pipe-20260311155325-udmh round-4
This commit is contained in:
parent
0277b6ba69
commit
950518431a
|
|
@ -0,0 +1,313 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 결재 단계 설정 패널
|
||||||
|
* 토스식 단계별 UX: 데이터 소스(Combobox) -> 레코드 식별(Combobox) -> 표시 설정(Switch+설명, Collapsible)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 { Check, ChevronsUpDown, Database, ChevronDown, Settings } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import type { ApprovalStepConfig } from "@/lib/registry/components/v2-approval-step/types";
|
||||||
|
|
||||||
|
interface V2ApprovalStepConfigPanelProps {
|
||||||
|
config: ApprovalStepConfig;
|
||||||
|
onChange: (config: Partial<ApprovalStepConfig>) => void;
|
||||||
|
screenTableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const V2ApprovalStepConfigPanel: React.FC<V2ApprovalStepConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
screenTableName,
|
||||||
|
}) => {
|
||||||
|
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [tableOpen, setTableOpen] = useState(false);
|
||||||
|
|
||||||
|
const [availableColumns, setAvailableColumns] = useState<Array<{ columnName: string; label: string }>>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
const [columnOpen, setColumnOpen] = useState(false);
|
||||||
|
|
||||||
|
const [displayOpen, setDisplayOpen] = useState(false);
|
||||||
|
|
||||||
|
const targetTableName = config.targetTable || screenTableName;
|
||||||
|
|
||||||
|
const handleChange = (key: keyof ApprovalStepConfig, value: any) => {
|
||||||
|
onChange({ [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTables = async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableTypeApi.getTables();
|
||||||
|
setAvailableTables(
|
||||||
|
response.map((table: any) => ({
|
||||||
|
tableName: table.tableName,
|
||||||
|
displayName: table.displayName || table.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||||
|
};
|
||||||
|
fetchTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetTableName) { setAvailableColumns([]); return; }
|
||||||
|
const fetchColumns = async () => {
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
|
||||||
|
if (columns && Array.isArray(columns)) {
|
||||||
|
setAvailableColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name || col.name,
|
||||||
|
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { setAvailableColumns([]); } finally { setLoadingColumns(false); }
|
||||||
|
};
|
||||||
|
fetchColumns();
|
||||||
|
}, [targetTableName]);
|
||||||
|
|
||||||
|
const handleTableChange = (newTableName: string) => {
|
||||||
|
if (newTableName === targetTableName) return;
|
||||||
|
handleChange("targetTable", newTableName);
|
||||||
|
handleChange("targetRecordIdField", "");
|
||||||
|
setTableOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ─── 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>
|
||||||
|
<p className="text-[11px] text-muted-foreground">결재 상태를 조회할 대상 테이블을 설정해요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">대상 테이블</span>
|
||||||
|
<Popover open={tableOpen} onOpenChange={setTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableOpen}
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
disabled={loadingTables}
|
||||||
|
>
|
||||||
|
{loadingTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: targetTableName
|
||||||
|
? availableTables.find((t) => t.tableName === targetTableName)?.displayName || targetTableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<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">
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName}`}
|
||||||
|
onSelect={() => handleTableChange(table.tableName)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", targetTableName === 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>
|
||||||
|
|
||||||
|
{screenTableName && targetTableName !== screenTableName && (
|
||||||
|
<div className="mt-1 flex items-center justify-between rounded-md bg-amber-50 px-2 py-1">
|
||||||
|
<span className="text-[10px] text-amber-700">
|
||||||
|
화면 기본 테이블({screenTableName})과 다른 테이블 사용 중
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
|
||||||
|
onClick={() => handleTableChange(screenTableName)}
|
||||||
|
>
|
||||||
|
기본으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레코드 ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">레코드 ID 필드</span>
|
||||||
|
{targetTableName ? (
|
||||||
|
<Popover open={columnOpen} onOpenChange={setColumnOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={columnOpen}
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
disabled={loadingColumns}
|
||||||
|
>
|
||||||
|
{loadingColumns
|
||||||
|
? "컬럼 로딩 중..."
|
||||||
|
: config.targetRecordIdField
|
||||||
|
? availableColumns.find((c) => c.columnName === config.targetRecordIdField)?.label || config.targetRecordIdField
|
||||||
|
: "PK 컬럼 선택"}
|
||||||
|
<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">
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={`${col.columnName} ${col.label}`}
|
||||||
|
onSelect={() => { handleChange("targetRecordIdField", col.columnName); setColumnOpen(false); }}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", config.targetRecordIdField === col.columnName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{col.label}</span>
|
||||||
|
{col.label !== col.columnName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{col.columnName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground">대상 테이블을 먼저 선택하세요</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">결재 대상 레코드를 식별할 PK 컬럼</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 2단계: 표시 모드 ─── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">표시 모드</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">결재 단계의 방향을 설정해요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4">
|
||||||
|
<Select
|
||||||
|
value={config.displayMode || "horizontal"}
|
||||||
|
onValueChange={(v) => handleChange("displayMode", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="horizontal">가로형 스테퍼</SelectItem>
|
||||||
|
<SelectItem value="vertical">세로형 타임라인</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 3단계: 표시 옵션 (Collapsible) ─── */}
|
||||||
|
<Collapsible open={displayOpen} onOpenChange={setDisplayOpen}>
|
||||||
|
<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", displayOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">부서/직급 표시</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">결재자의 부서와 직급을 보여줘요</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showDept !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showDept", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">결재 코멘트</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">결재자가 남긴 의견을 표시해요</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showComment !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showComment", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">처리 시각</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">각 단계의 처리 일시를 보여줘요</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showTimestamp !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showTimestamp", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">콤팩트 모드</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">좁은 공간에 맞게 작게 표시해요</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.compact || false}
|
||||||
|
onCheckedChange={(checked) => handleChange("compact", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
V2ApprovalStepConfigPanel.displayName = "V2ApprovalStepConfigPanel";
|
||||||
|
|
||||||
|
export default V2ApprovalStepConfigPanel;
|
||||||
|
|
@ -0,0 +1,618 @@
|
||||||
|
"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;
|
||||||
|
|
@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import type { WebType } from "@/types/screen";
|
import type { WebType } from "@/types/screen";
|
||||||
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
|
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
|
||||||
import { ApprovalStepConfigPanel } from "./ApprovalStepConfigPanel";
|
import { V2ApprovalStepConfigPanel as ApprovalStepConfigPanel } from "@/components/v2/config-panels/V2ApprovalStepConfigPanel";
|
||||||
import { ApprovalStepConfig } from "./types";
|
import { ApprovalStepConfig } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export type {
|
||||||
|
|
||||||
// 컴포넌트 내보내기
|
// 컴포넌트 내보내기
|
||||||
export { PivotGridComponent } from "./PivotGridComponent";
|
export { PivotGridComponent } from "./PivotGridComponent";
|
||||||
export { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
export { V2PivotGridConfigPanel as PivotGridConfigPanel } from "@/components/v2/config-panels/V2PivotGridConfigPanel";
|
||||||
|
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue