1008 lines
32 KiB
TypeScript
1008 lines
32 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* PivotGrid 설정 패널
|
|
* 화면 관리에서 PivotGrid 컴포넌트를 설정하는 UI
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
PivotGridComponentConfig,
|
|
PivotFieldConfig,
|
|
PivotAreaType,
|
|
AggregationType,
|
|
DateGroupInterval,
|
|
FieldDataType,
|
|
} from "./types";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@/components/ui/accordion";
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
GripVertical,
|
|
Settings2,
|
|
Rows,
|
|
Columns,
|
|
Database,
|
|
Filter,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
// ==================== 타입 ====================
|
|
|
|
interface TableInfo {
|
|
table_name: string;
|
|
table_comment?: string;
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
column_name: string;
|
|
data_type: string;
|
|
column_comment?: string;
|
|
is_nullable: string;
|
|
}
|
|
|
|
interface PivotGridConfigPanelProps {
|
|
config: PivotGridComponentConfig;
|
|
onChange: (config: PivotGridComponentConfig) => void;
|
|
}
|
|
|
|
// ==================== 유틸리티 ====================
|
|
|
|
const AREA_LABELS: Record<PivotAreaType, { label: string; icon: React.ReactNode }> = {
|
|
row: { label: "행 영역", icon: <Rows className="h-4 w-4" /> },
|
|
column: { label: "열 영역", icon: <Columns className="h-4 w-4" /> },
|
|
data: { label: "데이터 영역", icon: <Database className="h-4 w-4" /> },
|
|
filter: { label: "필터 영역", icon: <Filter className="h-4 w-4" /> },
|
|
};
|
|
|
|
const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [
|
|
{ value: "sum", label: "합계" },
|
|
{ value: "count", label: "개수" },
|
|
{ value: "avg", label: "평균" },
|
|
{ value: "min", label: "최소" },
|
|
{ value: "max", label: "최대" },
|
|
{ value: "countDistinct", label: "고유값 개수" },
|
|
];
|
|
|
|
const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [
|
|
{ value: "year", label: "연도" },
|
|
{ value: "quarter", label: "분기" },
|
|
{ value: "month", label: "월" },
|
|
{ value: "week", label: "주" },
|
|
{ value: "day", label: "일" },
|
|
];
|
|
|
|
const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [
|
|
{ value: "string", label: "문자열" },
|
|
{ value: "number", label: "숫자" },
|
|
{ value: "date", label: "날짜" },
|
|
{ value: "boolean", label: "부울" },
|
|
];
|
|
|
|
// DB 타입을 FieldDataType으로 변환
|
|
function mapDbTypeToFieldType(dbType: string): FieldDataType {
|
|
const type = dbType.toLowerCase();
|
|
if (
|
|
type.includes("int") ||
|
|
type.includes("numeric") ||
|
|
type.includes("decimal") ||
|
|
type.includes("float") ||
|
|
type.includes("double") ||
|
|
type.includes("real")
|
|
) {
|
|
return "number";
|
|
}
|
|
if (
|
|
type.includes("date") ||
|
|
type.includes("time") ||
|
|
type.includes("timestamp")
|
|
) {
|
|
return "date";
|
|
}
|
|
if (type.includes("bool")) {
|
|
return "boolean";
|
|
}
|
|
return "string";
|
|
}
|
|
|
|
// ==================== 필드 설정 컴포넌트 ====================
|
|
|
|
interface FieldConfigItemProps {
|
|
field: PivotFieldConfig;
|
|
index: number;
|
|
onChange: (field: PivotFieldConfig) => void;
|
|
onRemove: () => void;
|
|
onMoveUp: () => void;
|
|
onMoveDown: () => void;
|
|
isFirst: boolean;
|
|
isLast: boolean;
|
|
}
|
|
|
|
const FieldConfigItem: React.FC<FieldConfigItemProps> = ({
|
|
field,
|
|
index,
|
|
onChange,
|
|
onRemove,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
isFirst,
|
|
isLast,
|
|
}) => {
|
|
return (
|
|
<div className="flex items-start gap-2 p-2 rounded border border-border bg-background">
|
|
{/* 드래그 핸들 & 순서 버튼 */}
|
|
<div className="flex flex-col items-center gap-0.5 pt-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5"
|
|
onClick={onMoveUp}
|
|
disabled={isFirst}
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5"
|
|
onClick={onMoveDown}
|
|
disabled={isLast}
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 필드 설정 */}
|
|
<div className="flex-1 space-y-2">
|
|
{/* 필드명 & 라벨 */}
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<Label className="text-xs">필드명</Label>
|
|
<Input
|
|
value={field.field}
|
|
onChange={(e) => onChange({ ...field, field: e.target.value })}
|
|
placeholder="column_name"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<Label className="text-xs">표시 라벨</Label>
|
|
<Input
|
|
value={field.caption}
|
|
onChange={(e) => onChange({ ...field, caption: e.target.value })}
|
|
placeholder="표시명"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 데이터 타입 & 집계 함수 */}
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<Label className="text-xs">데이터 타입</Label>
|
|
<Select
|
|
value={field.dataType || "string"}
|
|
onValueChange={(v) =>
|
|
onChange({ ...field, dataType: v as FieldDataType })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DATA_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{field.area === "data" && (
|
|
<div className="flex-1">
|
|
<Label className="text-xs">집계 함수</Label>
|
|
<Select
|
|
value={field.summaryType || "sum"}
|
|
onValueChange={(v) =>
|
|
onChange({ ...field, summaryType: v as AggregationType })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGGREGATION_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{field.dataType === "date" &&
|
|
(field.area === "row" || field.area === "column") && (
|
|
<div className="flex-1">
|
|
<Label className="text-xs">그룹 단위</Label>
|
|
<Select
|
|
value={field.groupInterval || "__none__"}
|
|
onValueChange={(v) =>
|
|
onChange({
|
|
...field,
|
|
groupInterval:
|
|
v === "__none__" ? undefined : (v as DateGroupInterval),
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">원본값</SelectItem>
|
|
{DATE_GROUP_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
onClick={onRemove}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ==================== 영역별 필드 목록 ====================
|
|
|
|
interface AreaFieldListProps {
|
|
area: PivotAreaType;
|
|
fields: PivotFieldConfig[];
|
|
allColumns: ColumnInfo[];
|
|
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
|
}
|
|
|
|
const AreaFieldList: React.FC<AreaFieldListProps> = ({
|
|
area,
|
|
fields,
|
|
allColumns,
|
|
onFieldsChange,
|
|
}) => {
|
|
const areaFields = fields.filter((f) => f.area === area);
|
|
const { label, icon } = AREA_LABELS[area];
|
|
|
|
const handleAddField = () => {
|
|
const newField: PivotFieldConfig = {
|
|
field: "",
|
|
caption: "",
|
|
area,
|
|
areaIndex: areaFields.length,
|
|
dataType: "string",
|
|
visible: true,
|
|
};
|
|
if (area === "data") {
|
|
newField.summaryType = "sum";
|
|
}
|
|
onFieldsChange([...fields, newField]);
|
|
};
|
|
|
|
const handleAddFromColumn = (column: ColumnInfo) => {
|
|
const dataType = mapDbTypeToFieldType(column.data_type);
|
|
const newField: PivotFieldConfig = {
|
|
field: column.column_name,
|
|
caption: column.column_comment || column.column_name,
|
|
area,
|
|
areaIndex: areaFields.length,
|
|
dataType,
|
|
visible: true,
|
|
};
|
|
if (area === "data") {
|
|
newField.summaryType = "sum";
|
|
}
|
|
onFieldsChange([...fields, newField]);
|
|
};
|
|
|
|
const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => {
|
|
const newFields = [...fields];
|
|
const globalIndex = fields.findIndex(
|
|
(f) => f.area === area && f.areaIndex === index
|
|
);
|
|
if (globalIndex >= 0) {
|
|
newFields[globalIndex] = updatedField;
|
|
onFieldsChange(newFields);
|
|
}
|
|
};
|
|
|
|
const handleRemoveField = (index: number) => {
|
|
const newFields = fields.filter(
|
|
(f) => !(f.area === area && f.areaIndex === index)
|
|
);
|
|
// 인덱스 재정렬
|
|
let idx = 0;
|
|
newFields.forEach((f) => {
|
|
if (f.area === area) {
|
|
f.areaIndex = idx++;
|
|
}
|
|
});
|
|
onFieldsChange(newFields);
|
|
};
|
|
|
|
const handleMoveField = (fromIndex: number, direction: "up" | "down") => {
|
|
const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1;
|
|
if (toIndex < 0 || toIndex >= areaFields.length) return;
|
|
|
|
const newAreaFields = [...areaFields];
|
|
const [moved] = newAreaFields.splice(fromIndex, 1);
|
|
newAreaFields.splice(toIndex, 0, moved);
|
|
|
|
// 인덱스 재정렬
|
|
newAreaFields.forEach((f, idx) => {
|
|
f.areaIndex = idx;
|
|
});
|
|
|
|
// 전체 필드 업데이트
|
|
const newFields = fields.filter((f) => f.area !== area);
|
|
onFieldsChange([...newFields, ...newAreaFields]);
|
|
};
|
|
|
|
// 이미 추가된 컬럼 제외
|
|
const availableColumns = allColumns.filter(
|
|
(col) => !fields.some((f) => f.field === col.column_name)
|
|
);
|
|
|
|
return (
|
|
<AccordionItem value={area}>
|
|
<AccordionTrigger className="py-2">
|
|
<div className="flex items-center gap-2">
|
|
{icon}
|
|
<span>{label}</span>
|
|
<Badge variant="secondary" className="ml-2">
|
|
{areaFields.length}
|
|
</Badge>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="space-y-2 pt-2">
|
|
{/* 필드 목록 */}
|
|
{areaFields
|
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
|
|
.map((field, idx) => (
|
|
<FieldConfigItem
|
|
key={`${field.field}-${idx}`}
|
|
field={field}
|
|
index={field.areaIndex || idx}
|
|
onChange={(f) => handleFieldChange(field.areaIndex || idx, f)}
|
|
onRemove={() => handleRemoveField(field.areaIndex || idx)}
|
|
onMoveUp={() => handleMoveField(idx, "up")}
|
|
onMoveDown={() => handleMoveField(idx, "down")}
|
|
isFirst={idx === 0}
|
|
isLast={idx === areaFields.length - 1}
|
|
/>
|
|
))}
|
|
|
|
{/* 필드 추가 */}
|
|
<div className="flex gap-2">
|
|
<Select onValueChange={(v) => {
|
|
const col = allColumns.find(c => c.column_name === v);
|
|
if (col) handleAddFromColumn(col);
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs flex-1">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableColumns.length === 0 ? (
|
|
<SelectItem value="__none__" disabled>
|
|
추가 가능한 컬럼이 없습니다
|
|
</SelectItem>
|
|
) : (
|
|
availableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
{col.column_comment
|
|
? `${col.column_name} (${col.column_comment})`
|
|
: col.column_name}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={handleAddField}
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
직접 추가
|
|
</Button>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
};
|
|
|
|
// ==================== 메인 컴포넌트 ====================
|
|
|
|
export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
}) => {
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
|
|
// 테이블 목록 로드
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
|
|
const response = await apiClient.get("/table-management/tables");
|
|
if (response.data.success) {
|
|
setTables(response.data.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
// 테이블 선택 시 컬럼 로드
|
|
useEffect(() => {
|
|
const loadColumns = async () => {
|
|
if (!config.dataSource?.tableName) {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingColumns(true);
|
|
try {
|
|
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
|
|
const response = await apiClient.get(
|
|
`/table-management/tables/${config.dataSource.tableName}/columns`
|
|
);
|
|
if (response.data.success) {
|
|
setColumns(response.data.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
loadColumns();
|
|
}, [config.dataSource?.tableName]);
|
|
|
|
// 설정 업데이트 헬퍼
|
|
const updateConfig = useCallback(
|
|
(updates: Partial<PivotGridComponentConfig>) => {
|
|
onChange({ ...config, ...updates });
|
|
},
|
|
[config, onChange]
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 데이터 소스 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">데이터 소스</Label>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">테이블 선택</Label>
|
|
<Select
|
|
value={config.dataSource?.tableName || "__none__"}
|
|
onValueChange={(v) =>
|
|
updateConfig({
|
|
dataSource: {
|
|
...config.dataSource,
|
|
type: "table",
|
|
tableName: v === "__none__" ? undefined : v,
|
|
},
|
|
fields: [], // 테이블 변경 시 필드 초기화
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
|
{tables.map((table) => (
|
|
<SelectItem key={table.table_name} value={table.table_name}>
|
|
{table.table_comment
|
|
? `${table.table_name} (${table.table_comment})`
|
|
: table.table_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 필드 설정 */}
|
|
{config.dataSource?.tableName && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium">필드 설정</Label>
|
|
<Badge variant="outline">
|
|
{columns.length}개 컬럼
|
|
</Badge>
|
|
</div>
|
|
|
|
{loadingColumns ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
컬럼 로딩 중...
|
|
</div>
|
|
) : (
|
|
<Accordion
|
|
type="multiple"
|
|
defaultValue={["row", "column", "data"]}
|
|
className="w-full"
|
|
>
|
|
{(["row", "column", "data", "filter"] as PivotAreaType[]).map(
|
|
(area) => (
|
|
<AreaFieldList
|
|
key={area}
|
|
area={area}
|
|
fields={config.fields || []}
|
|
allColumns={columns}
|
|
onFieldsChange={(fields) => updateConfig({ fields })}
|
|
/>
|
|
)
|
|
)}
|
|
</Accordion>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* 표시 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">표시 설정</Label>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">행 총계</Label>
|
|
<Switch
|
|
checked={config.totals?.showRowGrandTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
totals: { ...config.totals, showRowGrandTotals: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">열 총계</Label>
|
|
<Switch
|
|
checked={config.totals?.showColumnGrandTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
totals: { ...config.totals, showColumnGrandTotals: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">행 소계</Label>
|
|
<Switch
|
|
checked={config.totals?.showRowTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
totals: { ...config.totals, showRowTotals: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">열 소계</Label>
|
|
<Switch
|
|
checked={config.totals?.showColumnTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
totals: { ...config.totals, showColumnTotals: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">줄 교차 색상</Label>
|
|
<Switch
|
|
checked={config.style?.alternateRowColors !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
style: { ...config.style, alternateRowColors: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">총계 강조</Label>
|
|
<Switch
|
|
checked={config.style?.highlightTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
style: { ...config.style, highlightTotals: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 기능 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">기능 설정</Label>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">전체 확장/축소 버튼</Label>
|
|
<Switch
|
|
checked={config.allowExpandAll !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({ allowExpandAll: v })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">CSV 내보내기</Label>
|
|
<Switch
|
|
checked={config.exportConfig?.excel === true}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
exportConfig: { ...config.exportConfig, excel: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 차트 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">차트 설정</Label>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">차트 표시</Label>
|
|
<Switch
|
|
checked={config.chart?.enabled === true}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
chart: {
|
|
...config.chart,
|
|
enabled: v,
|
|
type: config.chart?.type || "bar",
|
|
position: config.chart?.position || "bottom",
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{config.chart?.enabled && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs">차트 유형</Label>
|
|
<Select
|
|
value={config.chart?.type || "bar"}
|
|
onValueChange={(v) =>
|
|
updateConfig({
|
|
chart: {
|
|
...config.chart,
|
|
enabled: true,
|
|
type: v as "bar" | "line" | "area" | "pie" | "stackedBar",
|
|
position: config.chart?.position || "bottom",
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="bar">막대 차트</SelectItem>
|
|
<SelectItem value="stackedBar">누적 막대 차트</SelectItem>
|
|
<SelectItem value="line">선 차트</SelectItem>
|
|
<SelectItem value="area">영역 차트</SelectItem>
|
|
<SelectItem value="pie">파이 차트</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">차트 높이 (px)</Label>
|
|
<Input
|
|
type="number"
|
|
value={config.chart?.height || 300}
|
|
onChange={(e) =>
|
|
updateConfig({
|
|
chart: {
|
|
...config.chart,
|
|
enabled: true,
|
|
type: config.chart?.type || "bar",
|
|
position: config.chart?.position || "bottom",
|
|
height: Number(e.target.value),
|
|
},
|
|
})
|
|
}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">범례 표시</Label>
|
|
<Switch
|
|
checked={config.chart?.showLegend !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
chart: {
|
|
...config.chart,
|
|
enabled: true,
|
|
type: config.chart?.type || "bar",
|
|
position: config.chart?.position || "bottom",
|
|
showLegend: v,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 필드 선택기 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">필드 선택기 설정</Label>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">필드 선택기 활성화</Label>
|
|
<Switch
|
|
checked={config.fieldChooser?.enabled !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
fieldChooser: { ...config.fieldChooser, enabled: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">검색 허용</Label>
|
|
<Switch
|
|
checked={config.fieldChooser?.allowSearch !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({
|
|
fieldChooser: { ...config.fieldChooser, allowSearch: v },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 조건부 서식 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">조건부 서식</Label>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">Color Scale (색상 그라데이션)</Label>
|
|
<Switch
|
|
checked={
|
|
config.style?.conditionalFormats?.some(
|
|
(r) => r.type === "colorScale"
|
|
) || false
|
|
}
|
|
onCheckedChange={(v) => {
|
|
const existingFormats = config.style?.conditionalFormats || [];
|
|
const filtered = existingFormats.filter(
|
|
(r) => r.type !== "colorScale"
|
|
);
|
|
updateConfig({
|
|
style: {
|
|
...config.style,
|
|
theme: config.style?.theme || "default",
|
|
headerStyle: config.style?.headerStyle || "default",
|
|
cellPadding: config.style?.cellPadding || "normal",
|
|
borderStyle: config.style?.borderStyle || "light",
|
|
conditionalFormats: v
|
|
? [
|
|
...filtered,
|
|
{
|
|
id: "colorScale-1",
|
|
type: "colorScale" as const,
|
|
colorScale: {
|
|
minColor: "#ff6b6b",
|
|
midColor: "#ffd93d",
|
|
maxColor: "#6bcb77",
|
|
},
|
|
},
|
|
]
|
|
: filtered,
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">Data Bar (데이터 막대)</Label>
|
|
<Switch
|
|
checked={
|
|
config.style?.conditionalFormats?.some(
|
|
(r) => r.type === "dataBar"
|
|
) || false
|
|
}
|
|
onCheckedChange={(v) => {
|
|
const existingFormats = config.style?.conditionalFormats || [];
|
|
const filtered = existingFormats.filter(
|
|
(r) => r.type !== "dataBar"
|
|
);
|
|
updateConfig({
|
|
style: {
|
|
...config.style,
|
|
theme: config.style?.theme || "default",
|
|
headerStyle: config.style?.headerStyle || "default",
|
|
cellPadding: config.style?.cellPadding || "normal",
|
|
borderStyle: config.style?.borderStyle || "light",
|
|
conditionalFormats: v
|
|
? [
|
|
...filtered,
|
|
{
|
|
id: "dataBar-1",
|
|
type: "dataBar" as const,
|
|
dataBar: {
|
|
color: "#3b82f6",
|
|
showValue: true,
|
|
},
|
|
},
|
|
]
|
|
: filtered,
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">Icon Set (아이콘)</Label>
|
|
<Switch
|
|
checked={
|
|
config.style?.conditionalFormats?.some(
|
|
(r) => r.type === "iconSet"
|
|
) || false
|
|
}
|
|
onCheckedChange={(v) => {
|
|
const existingFormats = config.style?.conditionalFormats || [];
|
|
const filtered = existingFormats.filter(
|
|
(r) => r.type !== "iconSet"
|
|
);
|
|
updateConfig({
|
|
style: {
|
|
...config.style,
|
|
theme: config.style?.theme || "default",
|
|
headerStyle: config.style?.headerStyle || "default",
|
|
cellPadding: config.style?.cellPadding || "normal",
|
|
borderStyle: config.style?.borderStyle || "light",
|
|
conditionalFormats: v
|
|
? [
|
|
...filtered,
|
|
{
|
|
id: "iconSet-1",
|
|
type: "iconSet" as const,
|
|
iconSet: {
|
|
type: "traffic",
|
|
thresholds: [33, 66],
|
|
},
|
|
},
|
|
]
|
|
: filtered,
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{config.style?.conditionalFormats &&
|
|
config.style.conditionalFormats.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{config.style.conditionalFormats.length}개의 조건부 서식이
|
|
적용됨
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 크기 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">크기 설정</Label>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs">높이</Label>
|
|
<Input
|
|
value={config.height || ""}
|
|
onChange={(e) => updateConfig({ height: e.target.value })}
|
|
placeholder="auto 또는 400px"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">최대 높이</Label>
|
|
<Input
|
|
value={config.maxHeight || ""}
|
|
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
|
|
placeholder="600px"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PivotGridConfigPanel;
|
|
|