refactor: v2-table-grouped/v2-pivot-grid/v2-card-display 일괄 폐기 (T5)
T3 (본체) + T4 (ConfigPanel) 흡수 완료에 따라 옛 별도 컴포넌트 폐기.
v2-split-panel-layout 은 별도 컨테이너로 유지 (Codex 권고).
폴더 5개 삭제
- v2-table-grouped/ (537 + ConfigPanel)
- v2-pivot-grid/ (1963 + utils + components + hooks + ConfigPanel)
- v2-card-display/ (1314 + ConfigPanel)
- pivot-grid/ (legacy)
- card-display/ (legacy)
ConfigPanel 3개 삭제
- V2TableGroupedConfigPanel.tsx
- V2PivotGridConfigPanel.tsx
- V2CardDisplayConfigPanel.tsx
레지스트리 / alias / hidden 정리
- lib/registry/components/index.ts: 5개 import 라인 제거 (renderer 자동 등록 폐기)
- ComponentsPanel.tsx: hidden 목록의 v2-card-display ("→ stats" 잘못된 매핑) /
v2-table-grouped / v2-pivot-grid / pivot-grid / card-display 모두 제거
- DynamicComponentRenderer.tsx LEGACY_TO_UNIFIED: v2-card-display "→stats"
(잘못) / v2-table-grouped / v2-pivot-grid / card-display alias 제거
- getComponentConfigPanel.tsx: 4개 dynamic import + alias 제거
- templateMigrate.ts: 3 매핑 제거
- componentConfig.ts: v2PivotGridOverridesSchema, v2CardDisplayOverridesSchema
+ 등록 + defaults 제거
이벤트 dead code
- types/component-events.ts: RefreshCardDisplayDetail / REFRESH_CARD_DISPLAY
이벤트 (사용처 0건 — v2-card-display 전용) 제거
ScreenDesigner
- 4058 의 isCardDisplay 분기 (66.67% 그리드 특수 처리) 제거
- 4098 의 gridColumnsRatioMap 의 "card-display" 항목 제거
검증
- npx tsc --noEmit 우리 작업 파일 새 에러 0
- v2-table-grouped/v2-pivot-grid/v2-card-display/RefreshCardDisplay 잔존
grep — 의도적 주석 5곳만 (폐기 흔적 설명)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4055,7 +4055,6 @@ export default function ScreenDesigner({
|
||||
|
||||
// 컴포넌트별 gridColumns 설정 및 크기 계산
|
||||
let componentSize = component.defaultSize;
|
||||
const isCardDisplay = component.id === "card-display";
|
||||
const isTableList = component.id === "table-list";
|
||||
|
||||
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
||||
@@ -4063,9 +4062,7 @@ export default function ScreenDesigner({
|
||||
let gridColumns = 1; // 기본값
|
||||
|
||||
// 특수 컴포넌트
|
||||
if (isCardDisplay) {
|
||||
gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67%
|
||||
} else if (isTableList) {
|
||||
if (isTableList) {
|
||||
gridColumns = currentGridColumns; // 테이블은 전체 너비
|
||||
} else {
|
||||
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||
@@ -4095,7 +4092,6 @@ export default function ScreenDesigner({
|
||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||
"label-basic": 2 / 12, // 라벨 (16.67%)
|
||||
"text-display": 3 / 12, // 텍스트 표시 (25%)
|
||||
"card-display": 8 / 12, // 카드 (66.67%)
|
||||
"badge-basic": 1 / 12, // 배지 (8.33%)
|
||||
"alert-basic": 6 / 12, // 알림 (50%)
|
||||
"divider-basic": 1, // 구분선 (100%)
|
||||
|
||||
@@ -139,7 +139,6 @@ export function ComponentsPanel({
|
||||
"button-primary", // → v2-button-primary
|
||||
"split-panel-layout", // → v2-split-panel-layout
|
||||
"aggregation-widget", // → v2-aggregation-widget
|
||||
"card-display", // → v2-card-display
|
||||
"table-list", // → v2-table-list
|
||||
"text-display", // → v2-text-display
|
||||
"divider-line", // → v2-divider-line
|
||||
@@ -176,14 +175,11 @@ export function ComponentsPanel({
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
|
||||
"v2-aggregation-widget", // → stats
|
||||
"v2-status-count", // → stats
|
||||
"v2-card-display", // → stats
|
||||
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김
|
||||
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
|
||||
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
|
||||
"v2-table-list", // → table (displayMode='table')
|
||||
"v2-table-grouped", // → table (displayMode='grouped')
|
||||
"v2-pivot-grid", // → table (displayMode='pivot')
|
||||
"v2-split-panel-layout", // → table (displayMode='split')
|
||||
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
|
||||
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
|
||||
@@ -206,7 +202,6 @@ export function ComponentsPanel({
|
||||
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
|
||||
"repeat-container", // → v2-repeat-container
|
||||
"repeat-screen-modal", // → v2-repeat-screen-modal
|
||||
"pivot-grid", // → v2-pivot-grid
|
||||
"table-search-widget", // → v2-table-search-widget
|
||||
"tabs", // → v2-tabs
|
||||
"tabs-widget", // → v2-tabs-widget
|
||||
|
||||
@@ -1,789 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* V2CardDisplay 설정 패널
|
||||
* 토스식 단계별 UX: 테이블 선택 -> 컬럼 매핑 -> 카드 스타일 -> 고급 설정(접힘)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Database,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
// ─── 한 행당 카드 수 카드 정의 ───
|
||||
const CARDS_PER_ROW_OPTIONS = [
|
||||
{ value: 1, label: "1개" },
|
||||
{ value: 2, label: "2개" },
|
||||
{ value: 3, label: "3개" },
|
||||
{ value: 4, label: "4개" },
|
||||
{ value: 5, label: "5개" },
|
||||
{ value: 6, label: "6개" },
|
||||
] as const;
|
||||
|
||||
interface EntityJoinColumn {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}
|
||||
|
||||
interface JoinTable {
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: { sourceColumn: string };
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface V2CardDisplayConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
}
|
||||
|
||||
export const V2CardDisplayConfigPanel: React.FC<V2CardDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
tableColumns = [],
|
||||
}) => {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||
availableColumns: EntityJoinColumn[];
|
||||
joinTables: JoinTable[];
|
||||
}>({ availableColumns: [], joinTables: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
const newConfig = { ...config, [field]: value };
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNestedConfig = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current = newConfig;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) current[keys[i]] = {};
|
||||
current[keys[i]] = { ...current[keys[i]] };
|
||||
current = current[keys[i]];
|
||||
}
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(
|
||||
response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
|
||||
setAvailableColumns(tableColumns);
|
||||
return;
|
||||
}
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data?.columns) {
|
||||
setAvailableColumns(
|
||||
result.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [targetTableName, config.useCustomTable, tableColumns]);
|
||||
|
||||
// 엔티티 조인 컬럼 로드
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
return;
|
||||
}
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
|
||||
setEntityJoinColumns({
|
||||
availableColumns: result.availableColumns || [],
|
||||
joinTables: result.joinTables || [],
|
||||
});
|
||||
} catch {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
fetchEntityJoinColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
const handleTableSelect = (selectedTable: string, isScreenTable: boolean) => {
|
||||
const newConfig = isScreenTable
|
||||
? { ...config, useCustomTable: false, customTableName: undefined, tableName: selectedTable, columnMapping: { displayColumns: [] } }
|
||||
: { ...config, useCustomTable: true, customTableName: selectedTable, tableName: selectedTable, columnMapping: { displayColumns: [] } };
|
||||
onChange(newConfig);
|
||||
setTableComboboxOpen(false);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedTableDisplay = () => {
|
||||
if (!targetTableName) return "테이블을 선택하세요";
|
||||
const found = allTables.find((t) => t.tableName === targetTableName);
|
||||
return found?.displayName || targetTableName;
|
||||
};
|
||||
|
||||
const handleColumnSelect = (path: string, columnName: string) => {
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === columnName
|
||||
);
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === columnName);
|
||||
if (!exists) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
const newConfig = {
|
||||
...config,
|
||||
columnMapping: { ...config.columnMapping, [path.split(".")[1]]: columnName },
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
};
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateNestedConfig(path, columnName);
|
||||
};
|
||||
|
||||
// 표시 컬럼 관리
|
||||
const addDisplayColumn = () => {
|
||||
const current = config.columnMapping?.displayColumns || [];
|
||||
updateNestedConfig("columnMapping.displayColumns", [...current, ""]);
|
||||
};
|
||||
|
||||
const removeDisplayColumn = (index: number) => {
|
||||
const current = [...(config.columnMapping?.displayColumns || [])];
|
||||
current.splice(index, 1);
|
||||
updateNestedConfig("columnMapping.displayColumns", current);
|
||||
};
|
||||
|
||||
const updateDisplayColumn = (index: number, value: string) => {
|
||||
const current = [...(config.columnMapping?.displayColumns || [])];
|
||||
current[index] = value;
|
||||
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === value
|
||||
);
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === value);
|
||||
if (!exists) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
const newConfig = {
|
||||
...config,
|
||||
columnMapping: { ...config.columnMapping, displayColumns: current },
|
||||
joinColumns: [
|
||||
...joinColumnsConfig,
|
||||
{
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateNestedConfig("columnMapping.displayColumns", current);
|
||||
};
|
||||
|
||||
// 테이블별 조인 컬럼 그룹화
|
||||
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
|
||||
entityJoinColumns.availableColumns.forEach((col) => {
|
||||
if (!joinColumnsByTable[col.tableName]) joinColumnsByTable[col.tableName] = [];
|
||||
joinColumnsByTable[col.tableName].push(col);
|
||||
});
|
||||
|
||||
const currentTableColumns = config.useCustomTable
|
||||
? availableColumns
|
||||
: tableColumns.length > 0
|
||||
? tableColumns
|
||||
: availableColumns;
|
||||
|
||||
// 컬럼 선택 Select 렌더링
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChangeHandler: (value: string) => void,
|
||||
placeholder: string = "컬럼 선택"
|
||||
) => (
|
||||
<Select
|
||||
value={value || "__none__"}
|
||||
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[11px]">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[11px] text-muted-foreground">
|
||||
선택 안함
|
||||
</SelectItem>
|
||||
{currentTableColumns.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-semibold text-muted-foreground">
|
||||
기본 컬럼
|
||||
</SelectLabel>
|
||||
{currentTableColumns.map((column: any) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName} className="text-[11px]">
|
||||
{column.columnLabel || column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
|
||||
<SelectGroup key={tableName}>
|
||||
<SelectLabel className="text-[10px] font-semibold text-primary">
|
||||
{tableName} (조인)
|
||||
</SelectLabel>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.joinAlias} value={col.joinAlias} className="text-[11px]">
|
||||
{col.suggestedLabel || col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ─── 1단계: 테이블 선택 ─── */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">데이터 소스</p>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{loadingTables ? "로딩 중..." : getSelectedTableDisplay()}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없어요
|
||||
</CommandEmpty>
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => handleTableSelect(screenTableName, true)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTableName === screenTableName && !config.useCustomTable
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-primary" />
|
||||
{allTables.find((t) => t.tableName === screenTableName)?.displayName ||
|
||||
screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter((t) => t.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => handleTableSelect(table.tableName, false)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.useCustomTable && targetTableName === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{table.displayName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.useCustomTable && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── 2단계: 컬럼 매핑 ─── */}
|
||||
{(currentTableColumns.length > 0 || loadingColumns) && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium truncate">컬럼 매핑</p>
|
||||
|
||||
{(loadingEntityJoins || loadingColumns) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">타이틀</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.titleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.titleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">서브타이틀</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.subtitleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">설명</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.descriptionColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">이미지</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.imageColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.imageColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium truncate">추가 표시 컬럼</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addDisplayColumn}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(config.columnMapping?.displayColumns || []).length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{(config.columnMapping?.displayColumns || []).map(
|
||||
(column: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{renderColumnSelect(column, (value) =>
|
||||
updateDisplayColumn(index, value)
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisplayColumn(index)}
|
||||
className="h-7 w-7 shrink-0 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
|
||||
추가 버튼으로 표시할 컬럼을 추가해요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 3단계: 카드 레이아웃 ─── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium truncate">카드 레이아웃</p>
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">한 행당 카드 수</span>
|
||||
<Select
|
||||
value={String(config.cardsPerRow || 3)}
|
||||
onValueChange={(v) => updateConfig("cardsPerRow", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[100px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CARDS_PER_ROW_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">카드 간격 (px)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={config.cardSpacing ?? 16}
|
||||
onChange={(e) => updateConfig("cardSpacing", parseInt(e.target.value))}
|
||||
className="h-7 w-[100px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 4단계: 표시 요소 토글 ─── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium truncate">표시 요소</p>
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
|
||||
<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.cardStyle?.showTitle ?? true}
|
||||
onCheckedChange={(checked) => updateNestedConfig("cardStyle.showTitle", 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.cardStyle?.showSubtitle ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showSubtitle", 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.cardStyle?.showDescription ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showDescription", 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.cardStyle?.showImage ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showImage", 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.cardStyle?.showActions ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showActions", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.cardStyle?.showActions ?? true) && (
|
||||
<div className="ml-4 border-l-2 border-primary/20 pl-3 space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">상세보기</span>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showViewButton ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showViewButton", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">편집</span>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showEditButton ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showEditButton", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">삭제</span>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showDeleteButton ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showDeleteButton", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */}
|
||||
<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 truncate">고급 설정</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">3개</Badge>
|
||||
</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-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground truncate">설명 최대 길이</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={500}
|
||||
value={config.cardStyle?.maxDescriptionLength ?? 100}
|
||||
onChange={(e) =>
|
||||
updateNestedConfig(
|
||||
"cardStyle.maxDescriptionLength",
|
||||
parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
className="h-7 w-[100px] text-xs"
|
||||
/>
|
||||
</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.disabled || false}
|
||||
onCheckedChange={(checked) => updateConfig("disabled", 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.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2CardDisplayConfigPanel.displayName = "V2CardDisplayConfigPanel";
|
||||
|
||||
export default V2CardDisplayConfigPanel;
|
||||
@@ -1,804 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* V2 피벗 그리드 설정 패널
|
||||
* 토스식 단계별 UX: 테이블 선택(Combobox) -> 필드 배치(AreaDropZone) -> 고급 설정(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,
|
||||
ConditionalFormatRule,
|
||||
} 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;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
|
||||
area,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
fields,
|
||||
columns,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onUpdateField,
|
||||
color,
|
||||
}) => {
|
||||
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", color)}>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
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="mt-1 text-xs text-muted-foreground">{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>
|
||||
);
|
||||
};
|
||||
|
||||
const STYLE_DEFAULTS: { theme: "default"; headerStyle: "default"; cellPadding: "normal"; borderStyle: "light" } = {
|
||||
theme: "default",
|
||||
headerStyle: "default",
|
||||
cellPadding: "normal",
|
||||
borderStyle: "light",
|
||||
};
|
||||
|
||||
/* ─── 메인 컴포넌트 ─── */
|
||||
|
||||
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(() => {
|
||||
if (!config.dataSource?.tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
const loadColumns = async () => {
|
||||
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>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: newConfig },
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[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 handleTableChange = (tableName: string) => {
|
||||
updateConfig({
|
||||
dataSource: { ...config.dataSource, type: "table", tableName },
|
||||
fields: [],
|
||||
});
|
||||
setTableOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ─── 안내 ─── */}
|
||||
<div className="rounded-lg border border-primary/20 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">
|
||||
<p className="mb-1 font-medium">피벗 테이블 설정 방법</p>
|
||||
<ol className="list-inside list-decimal space-y-0.5">
|
||||
<li>데이터를 가져올 <strong>테이블</strong>을 선택</li>
|
||||
<li><strong>행 그룹</strong>에 그룹화 컬럼 추가 (예: 지역, 부서)</li>
|
||||
<li><strong>열 그룹</strong>에 가로 펼칠 컬럼 추가 (예: 월, 분기)</li>
|
||||
<li><strong>값</strong>에 집계할 숫자 컬럼 추가 (예: 매출, 수량)</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>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
피벗 분석에 사용할 데이터 테이블을 골라요
|
||||
</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
|
||||
? "로딩 중..."
|
||||
: config.dataSource?.tableName
|
||||
? tables.find((t) => t.tableName === config.dataSource?.tableName)?.displayName ||
|
||||
config.dataSource.tableName
|
||||
: "테이블 선택"}
|
||||
<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={() => handleTableChange(table.tableName)}
|
||||
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>
|
||||
|
||||
{/* ─── 2단계: 필드 배치 ─── */}
|
||||
{config.dataSource?.tableName && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rows className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium truncate">필드 배치</p>
|
||||
{loadingColumns && (
|
||||
<span className="text-[11px] text-muted-foreground">(컬럼 로딩 중...)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
각 영역에 컬럼을 추가하여 피벗 구조를 만들어요
|
||||
</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, updates) => handleUpdateField("row", idx, updates)}
|
||||
color="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, updates) => handleUpdateField("column", idx, updates)}
|
||||
color="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, updates) => handleUpdateField("data", idx, updates)}
|
||||
color="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 truncate">고급 설정</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">12개</Badge>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
advancedOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
|
||||
{/* 총계 설정 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">총계 설정</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 truncate">행 총계</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 truncate">열 총계</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 truncate">행 총계 위치</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 truncate">열 총계 위치</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 truncate">행 소계</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 truncate">열 소계</span>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">스타일 설정</p>
|
||||
<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.style?.alternateRowColors !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, alternateRowColors: v } })
|
||||
}
|
||||
/>
|
||||
</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.style?.mergeCells === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, mergeCells: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 설정 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">크기 설정</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<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 className="space-y-1">
|
||||
<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-3">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">기능 설정</p>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">CSV 내보내기</p>
|
||||
<p className="text-[11px] text-muted-foreground">데이터를 CSV 파일로 내보낼 수 있어요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.exportConfig?.excel === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
|
||||
}
|
||||
/>
|
||||
</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.allowExpandAll !== false}
|
||||
onCheckedChange={(v) => updateConfig({ allowExpandAll: v })}
|
||||
/>
|
||||
</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.allowFiltering !== false}
|
||||
onCheckedChange={(v) => updateConfig({ allowFiltering: v })}
|
||||
/>
|
||||
</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.allowSortingBySummary !== false}
|
||||
onCheckedChange={(v) => updateConfig({ allowSortingBySummary: v })}
|
||||
/>
|
||||
</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.wordWrapEnabled === true}
|
||||
onCheckedChange={(v) => updateConfig({ wordWrapEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건부 서식 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">조건부 서식</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 ConditionalFormatRule["type"] };
|
||||
updateConfig({ style: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...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 "arrows" | "traffic" | "rating" | "flags", thresholds: [33, 67] } };
|
||||
updateConfig({ style: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건부 서식 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default V2PivotGridConfigPanel;
|
||||
@@ -1,771 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* V2TableGrouped 설정 패널
|
||||
* 토스식 단계별 UX: 데이터 소스 -> 그룹화 설정 -> 컬럼 선택 -> 표시 설정(접힘) -> 연동 설정(접힘)
|
||||
* 기존 TableGroupedConfigPanel의 모든 기능을 자체 UI로 완전 구현
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table2,
|
||||
Database,
|
||||
Layers,
|
||||
Columns3,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Link2,
|
||||
Plus,
|
||||
Trash2,
|
||||
FoldVertical,
|
||||
ArrowUpDown,
|
||||
CheckSquare,
|
||||
LayoutGrid,
|
||||
Type,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { TableGroupedConfig, LinkedFilterConfig } from "@/lib/registry/components/v2-table-grouped/types";
|
||||
import type { ColumnConfig } from "@/lib/registry/components/v2-table-list/types";
|
||||
import {
|
||||
groupHeaderStyleOptions,
|
||||
checkboxModeOptions,
|
||||
sortDirectionOptions,
|
||||
} from "@/lib/registry/components/v2-table-grouped/config";
|
||||
|
||||
// ─── 섹션 헤더 컴포넌트 ───
|
||||
function SectionHeader({ icon: Icon, title, description }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</div>
|
||||
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수평 Switch Row (토스 패턴) ───
|
||||
function SwitchRow({ label, description, checked, onCheckedChange }: {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm">{label}</p>
|
||||
{description && <p className="text-[11px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수평 라벨 + 컨트롤 Row ───
|
||||
function LabeledRow({ label, description, children }: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 그룹 헤더 스타일 카드 ───
|
||||
const HEADER_STYLE_CARDS = [
|
||||
{ value: "default", icon: LayoutGrid, title: "기본", description: "표준 그룹 헤더" },
|
||||
{ value: "compact", icon: FoldVertical, title: "컴팩트", description: "간결한 헤더" },
|
||||
{ value: "card", icon: Layers, title: "카드", description: "카드 스타일 헤더" },
|
||||
] as const;
|
||||
|
||||
interface V2TableGroupedConfigPanelProps {
|
||||
config: TableGroupedConfig;
|
||||
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
|
||||
}
|
||||
|
||||
export const V2TableGroupedConfigPanel: React.FC<V2TableGroupedConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// componentConfigChanged 이벤트 발행 래퍼
|
||||
const handleChange = useCallback((newConfig: Partial<TableGroupedConfig>) => {
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: { ...config, ...newConfig } },
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [onChange, config]);
|
||||
|
||||
const updateConfig = useCallback((updates: Partial<TableGroupedConfig>) => {
|
||||
handleChange({ ...config, ...updates });
|
||||
}, [handleChange, config]);
|
||||
|
||||
const updateGroupConfig = useCallback((updates: Partial<TableGroupedConfig["groupConfig"]>) => {
|
||||
handleChange({
|
||||
...config,
|
||||
groupConfig: { ...config.groupConfig, ...updates },
|
||||
});
|
||||
}, [handleChange, config]);
|
||||
|
||||
// ─── 상태 ───
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
|
||||
// Collapsible 상태
|
||||
const [displayOpen, setDisplayOpen] = useState(false);
|
||||
const [linkedOpen, setLinkedOpen] = useState(false);
|
||||
|
||||
// ─── 실제 사용할 테이블 이름 ───
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.selectedTable;
|
||||
}, [config.useCustomTable, config.customTableName, config.selectedTable]);
|
||||
|
||||
// ─── 테이블 목록 로드 ───
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
if (tableList && Array.isArray(tableList)) {
|
||||
setTables(
|
||||
tableList.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.displayName || t.display_name || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("테이블 목록 로드 실패:", err);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// ─── 선택된 테이블의 컬럼 로드 ───
|
||||
useEffect(() => {
|
||||
if (!targetTableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(targetTableName);
|
||||
if (columns && Array.isArray(columns)) {
|
||||
const cols: ColumnConfig[] = columns.map((col: any, idx: number) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||
visible: true,
|
||||
sortable: true,
|
||||
searchable: false,
|
||||
align: "left" as const,
|
||||
order: idx,
|
||||
}));
|
||||
setTableColumns(cols);
|
||||
|
||||
if (!config.columns || config.columns.length === 0) {
|
||||
updateConfig({ columns: cols });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("컬럼 로드 실패:", err);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetTableName]);
|
||||
|
||||
// ─── 테이블 변경 핸들러 ───
|
||||
const handleTableChange = useCallback((newTableName: string) => {
|
||||
if (newTableName === config.selectedTable) return;
|
||||
updateConfig({ selectedTable: newTableName, columns: [] });
|
||||
setTableComboboxOpen(false);
|
||||
}, [config.selectedTable, updateConfig]);
|
||||
|
||||
// ─── 컬럼 가시성 토글 ───
|
||||
const toggleColumnVisibility = useCallback((columnName: string) => {
|
||||
const updatedColumns = (config.columns || []).map((col) =>
|
||||
col.columnName === columnName ? { ...col, visible: !col.visible } : col
|
||||
);
|
||||
updateConfig({ columns: updatedColumns });
|
||||
}, [config.columns, updateConfig]);
|
||||
|
||||
// ─── 합계 컬럼 토글 ───
|
||||
const toggleSumColumn = useCallback((columnName: string) => {
|
||||
const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
|
||||
const newSumCols = currentSumCols.includes(columnName)
|
||||
? currentSumCols.filter((c) => c !== columnName)
|
||||
: [...currentSumCols, columnName];
|
||||
|
||||
updateGroupConfig({
|
||||
summary: {
|
||||
...config.groupConfig?.summary,
|
||||
sumColumns: newSumCols,
|
||||
},
|
||||
});
|
||||
}, [config.groupConfig?.summary, updateGroupConfig]);
|
||||
|
||||
// ─── 연결 필터 관리 ───
|
||||
const addLinkedFilter = useCallback(() => {
|
||||
const newFilter: LinkedFilterConfig = {
|
||||
sourceComponentId: "",
|
||||
sourceField: "value",
|
||||
targetColumn: "",
|
||||
enabled: true,
|
||||
};
|
||||
updateConfig({
|
||||
linkedFilters: [...(config.linkedFilters || []), newFilter],
|
||||
});
|
||||
}, [config.linkedFilters, updateConfig]);
|
||||
|
||||
const removeLinkedFilter = useCallback((index: number) => {
|
||||
const filters = [...(config.linkedFilters || [])];
|
||||
filters.splice(index, 1);
|
||||
updateConfig({ linkedFilters: filters });
|
||||
}, [config.linkedFilters, updateConfig]);
|
||||
|
||||
const updateLinkedFilter = useCallback((index: number, updates: Partial<LinkedFilterConfig>) => {
|
||||
const filters = [...(config.linkedFilters || [])];
|
||||
filters[index] = { ...filters[index], ...updates };
|
||||
updateConfig({ linkedFilters: filters });
|
||||
}, [config.linkedFilters, updateConfig]);
|
||||
|
||||
// ─── 렌더링 ───
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 1단계: 데이터 소스 (테이블 선택) */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
<div className="space-y-3">
|
||||
<SectionHeader icon={Table2} title="데이터 소스" description="그룹화할 테이블을 선택하세요" />
|
||||
<Separator />
|
||||
|
||||
<SwitchRow
|
||||
label="커스텀 테이블 사용"
|
||||
description="화면 메인 테이블 대신 다른 테이블을 사용합니다"
|
||||
checked={config.useCustomTable ?? false}
|
||||
onCheckedChange={(checked) => updateConfig({ useCustomTable: checked })}
|
||||
/>
|
||||
|
||||
{config.useCustomTable ? (
|
||||
<Input
|
||||
value={config.customTableName || ""}
|
||||
onChange={(e) => updateConfig({ customTableName: e.target.value })}
|
||||
placeholder="테이블명을 직접 입력하세요"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Table2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{loadingTables
|
||||
? "테이블 로딩 중..."
|
||||
: config.selectedTable
|
||||
? tables.find((t) => t.tableName === config.selectedTable)?.displayName || config.selectedTable
|
||||
: "테이블 선택"}
|
||||
</span>
|
||||
</div>
|
||||
<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
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableChange(table.tableName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", config.selectedTable === table.tableName ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
{table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 2단계: 그룹화 설정 */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{targetTableName && (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader icon={Layers} title="그룹화 설정" description="데이터를 어떤 컬럼 기준으로 그룹화할지 설정합니다" />
|
||||
<Separator />
|
||||
|
||||
{/* 그룹화 기준 컬럼 */}
|
||||
<LabeledRow label="그룹화 기준 컬럼 *">
|
||||
<Select
|
||||
value={config.groupConfig?.groupByColumn || ""}
|
||||
onValueChange={(value) => updateGroupConfig({ groupByColumn: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(loadingColumns ? [] : tableColumns).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</LabeledRow>
|
||||
|
||||
{/* 그룹 라벨 형식 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium truncate">그룹 라벨 형식</span>
|
||||
</div>
|
||||
<Input
|
||||
value={config.groupConfig?.groupLabelFormat || "{value}"}
|
||||
onChange={(e) => updateGroupConfig({ groupLabelFormat: e.target.value })}
|
||||
placeholder="{value} ({컬럼명})"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SwitchRow
|
||||
label="기본 펼침 상태"
|
||||
description="그룹이 기본으로 펼쳐진 상태로 표시됩니다"
|
||||
checked={config.groupConfig?.defaultExpanded ?? true}
|
||||
onCheckedChange={(checked) => updateGroupConfig({ defaultExpanded: checked })}
|
||||
/>
|
||||
|
||||
{/* 그룹 정렬 */}
|
||||
<LabeledRow label="그룹 정렬">
|
||||
<Select
|
||||
value={config.groupConfig?.sortDirection || "asc"}
|
||||
onValueChange={(value: string) => updateGroupConfig({ sortDirection: value as "asc" | "desc" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[120px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortDirectionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</LabeledRow>
|
||||
|
||||
<SwitchRow
|
||||
label="개수 표시"
|
||||
description="그룹 헤더에 항목 수를 표시합니다"
|
||||
checked={config.groupConfig?.summary?.showCount ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateGroupConfig({
|
||||
summary: { ...config.groupConfig?.summary, showCount: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 합계 컬럼 */}
|
||||
{tableColumns.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium truncate">합계 표시 컬럼</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">그룹별 합계를 계산할 컬럼을 선택하세요</p>
|
||||
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
||||
{tableColumns.map((col) => {
|
||||
const isChecked = config.groupConfig?.summary?.sumColumns?.includes(col.columnName) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50",
|
||||
isChecked && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleSumColumn(col.columnName)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleSumColumn(col.columnName)}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate text-xs">{col.displayName || col.columnName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 미선택 안내 */}
|
||||
{!targetTableName && (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
|
||||
<p className="text-sm text-muted-foreground">테이블이 선택되지 않았습니다</p>
|
||||
<p className="text-xs text-muted-foreground">위 데이터 소스에서 테이블을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 3단계: 컬럼 선택 */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{targetTableName && (config.columns || tableColumns).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader
|
||||
icon={Columns3}
|
||||
title={`컬럼 선택 (${(config.columns || tableColumns).filter((c) => c.visible !== false).length}개 표시)`}
|
||||
description="표시할 컬럼을 선택하세요"
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
||||
{(config.columns || tableColumns).map((col) => {
|
||||
const isVisible = col.visible !== false;
|
||||
return (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50",
|
||||
isVisible && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleColumnVisibility(col.columnName)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => toggleColumnVisibility(col.columnName)}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Database className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs">{col.displayName || col.columnName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 4단계: 그룹 헤더 스타일 (카드 선택) */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{targetTableName && (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader icon={LayoutGrid} title="그룹 헤더 스타일" description="그룹 헤더의 디자인을 선택하세요" />
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{HEADER_STYLE_CARDS.map((card) => {
|
||||
const Icon = card.icon;
|
||||
const isSelected = (config.groupHeaderStyle || "default") === card.value;
|
||||
return (
|
||||
<button
|
||||
key={card.value}
|
||||
type="button"
|
||||
onClick={() => updateConfig({ groupHeaderStyle: card.value as "default" | "compact" | "card" })}
|
||||
className={cn(
|
||||
"flex min-h-[70px] flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="mb-1 h-4 w-4 text-primary" />
|
||||
<span className="text-xs font-medium leading-tight">{card.title}</span>
|
||||
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 5단계: 표시 설정 (기본 접힘) */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
<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 truncate">표시 설정</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">6개</Badge>
|
||||
</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="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium truncate">체크박스</span>
|
||||
</div>
|
||||
|
||||
<SwitchRow
|
||||
label="체크박스 표시"
|
||||
description="행 선택용 체크박스를 표시합니다"
|
||||
checked={config.showCheckbox ?? false}
|
||||
onCheckedChange={(checked) => updateConfig({ showCheckbox: checked })}
|
||||
/>
|
||||
|
||||
{config.showCheckbox && (
|
||||
<div className="ml-4 border-l-2 border-primary/20 pl-3">
|
||||
<LabeledRow label="선택 모드">
|
||||
<Select
|
||||
value={config.checkboxMode || "multi"}
|
||||
onValueChange={(value: string) => updateConfig({ checkboxMode: value as "single" | "multi" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[120px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{checkboxModeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</LabeledRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* UI 옵션 */}
|
||||
<SwitchRow
|
||||
label="펼치기/접기 버튼 표시"
|
||||
description="전체 펼치기/접기 버튼을 상단에 표시합니다"
|
||||
checked={config.showExpandAllButton ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ showExpandAllButton: checked })}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="행 클릭 가능"
|
||||
description="행 클릭 시 이벤트를 발생시킵니다"
|
||||
checked={config.rowClickable ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ rowClickable: checked })}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 높이 및 메시지 */}
|
||||
<LabeledRow label="최대 높이 (px)">
|
||||
<Input
|
||||
type="number"
|
||||
value={typeof config.maxHeight === "number" ? config.maxHeight : 600}
|
||||
onChange={(e) => updateConfig({ maxHeight: parseInt(e.target.value) || 600 })}
|
||||
min={200}
|
||||
max={2000}
|
||||
className="h-7 w-[100px] text-xs"
|
||||
/>
|
||||
</LabeledRow>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground truncate">빈 데이터 메시지</span>
|
||||
<Input
|
||||
value={config.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
|
||||
placeholder="데이터가 없습니다."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 6단계: 연동 설정 (기본 접힘) */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
<Collapsible open={linkedOpen} onOpenChange={setLinkedOpen}>
|
||||
<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">
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">연동 설정</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">{config.linkedFilters?.length || 0}개</Badge>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", linkedOpen && "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">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLinkedFilter}
|
||||
className="h-6 shrink-0 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(config.linkedFilters || []).length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed py-4 text-center">
|
||||
<Link2 className="mx-auto mb-1 h-6 w-6 text-muted-foreground opacity-30" />
|
||||
<p className="text-xs text-muted-foreground">연결된 필터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.linkedFilters || []).map((filter, idx) => (
|
||||
<div key={idx} className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">필터 #{idx + 1}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={filter.enabled !== false}
|
||||
onCheckedChange={(checked) => updateLinkedFilter(idx, { enabled: checked })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLinkedFilter(idx)}
|
||||
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">소스 컴포넌트 ID</span>
|
||||
<Input
|
||||
value={filter.sourceComponentId}
|
||||
onChange={(e) => updateLinkedFilter(idx, { sourceComponentId: e.target.value })}
|
||||
placeholder="예: search-filter-1"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">소스 필드</span>
|
||||
<Input
|
||||
value={filter.sourceField || "value"}
|
||||
onChange={(e) => updateLinkedFilter(idx, { sourceField: e.target.value })}
|
||||
placeholder="value"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">대상 컬럼</span>
|
||||
<Select
|
||||
value={filter.targetColumn}
|
||||
onValueChange={(value) => updateLinkedFilter(idx, { targetColumn: value })}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2TableGroupedConfigPanel.displayName = "V2TableGroupedConfigPanel";
|
||||
|
||||
export default V2TableGroupedConfigPanel;
|
||||
@@ -380,10 +380,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
"slider-basic": "input", "radio-basic": "input", "toggle-switch": "input",
|
||||
// stats
|
||||
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
|
||||
"v2-status-count": "stats", "v2-card-display": "stats", "card-display": "stats",
|
||||
"v2-status-count": "stats",
|
||||
// table
|
||||
"v2-table-list": "table", "table-list": "table",
|
||||
"v2-table-grouped": "table", "v2-pivot-grid": "table",
|
||||
// container
|
||||
"v2-tabs-widget": "container", "v2-section-card": "container",
|
||||
"v2-section-paper": "container", "v2-repeat-container": "container",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,732 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardDisplayConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
}
|
||||
|
||||
interface EntityJoinColumn {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}
|
||||
|
||||
interface JoinTable {
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: {
|
||||
sourceColumn: string;
|
||||
};
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 설정 패널
|
||||
* 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원
|
||||
*/
|
||||
export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
tableColumns = [],
|
||||
}) => {
|
||||
// 테이블 선택 상태
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 엔티티 조인 컬럼 상태
|
||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||
availableColumns: EntityJoinColumn[];
|
||||
joinTables: JoinTable[];
|
||||
}>({ availableColumns: [], joinTables: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 현재 사용할 테이블명
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 커스텀 테이블이 아니면 props로 받은 tableColumns 사용
|
||||
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
|
||||
setAvailableColumns(tableColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data?.columns) {
|
||||
setAvailableColumns(result.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [targetTableName, config.useCustomTable, tableColumns]);
|
||||
|
||||
// 엔티티 조인 컬럼 정보 가져오기
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
|
||||
setEntityJoinColumns({
|
||||
availableColumns: result.availableColumns || [],
|
||||
joinTables: result.joinTables || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEntityJoinColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableSelect = (tableName: string, isScreenTable: boolean) => {
|
||||
if (isScreenTable) {
|
||||
// 화면 기본 테이블 선택
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: false,
|
||||
customTableName: undefined,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
|
||||
});
|
||||
} else {
|
||||
// 다른 테이블 선택
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: true,
|
||||
customTableName: tableName,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
|
||||
});
|
||||
}
|
||||
setTableComboboxOpen(false);
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블 표시명 가져오기
|
||||
const getSelectedTableDisplay = () => {
|
||||
if (!targetTableName) return "테이블을 선택하세요";
|
||||
const found = allTables.find(t => t.tableName === targetTableName);
|
||||
return found?.displayName || targetTableName;
|
||||
};
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
const handleNestedChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
let newConfig = { ...config };
|
||||
let current = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트
|
||||
const handleColumnSelect = (path: string, columnName: string) => {
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === columnName
|
||||
);
|
||||
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const existingJoinColumn = joinColumnsConfig.find(
|
||||
(jc: any) => jc.columnName === columnName
|
||||
);
|
||||
|
||||
if (!existingJoinColumn) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: {
|
||||
...config.columnMapping,
|
||||
[path.split(".")[1]]: columnName,
|
||||
},
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleNestedChange(path, columnName);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = () => {
|
||||
const currentColumns = config.columnMapping?.displayColumns || [];
|
||||
const newColumns = [...currentColumns, ""];
|
||||
handleNestedChange("columnMapping.displayColumns", newColumns);
|
||||
};
|
||||
|
||||
// 표시 컬럼 삭제
|
||||
const removeDisplayColumn = (index: number) => {
|
||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||
currentColumns.splice(index, 1);
|
||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||
};
|
||||
|
||||
// 표시 컬럼 값 변경
|
||||
const updateDisplayColumn = (index: number, value: string) => {
|
||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||
currentColumns[index] = value;
|
||||
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === value
|
||||
);
|
||||
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const existingJoinColumn = joinColumnsConfig.find(
|
||||
(jc: any) => jc.columnName === value
|
||||
);
|
||||
|
||||
if (!existingJoinColumn) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: {
|
||||
...config.columnMapping,
|
||||
displayColumns: currentColumns,
|
||||
},
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||
};
|
||||
|
||||
// 테이블별로 조인 컬럼 그룹화
|
||||
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
|
||||
entityJoinColumns.availableColumns.forEach((col) => {
|
||||
if (!joinColumnsByTable[col.tableName]) {
|
||||
joinColumnsByTable[col.tableName] = [];
|
||||
}
|
||||
joinColumnsByTable[col.tableName].push(col);
|
||||
});
|
||||
|
||||
// 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props)
|
||||
const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns);
|
||||
|
||||
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChangeHandler: (value: string) => void,
|
||||
placeholder: string = "컬럼을 선택하세요"
|
||||
) => {
|
||||
return (
|
||||
<Select
|
||||
value={value || "__none__"}
|
||||
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 선택 안함 옵션 */}
|
||||
<SelectItem value="__none__" className="text-xs text-muted-foreground">
|
||||
선택 안함
|
||||
</SelectItem>
|
||||
|
||||
{/* 기본 테이블 컬럼 */}
|
||||
{currentTableColumns.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs font-semibold text-muted-foreground">
|
||||
기본 컬럼
|
||||
</SelectLabel>
|
||||
{currentTableColumns.map((column: any) => (
|
||||
<SelectItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{column.columnLabel || column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* 조인 테이블별 컬럼 */}
|
||||
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
|
||||
<SelectGroup key={tableName}>
|
||||
<SelectLabel className="text-xs font-semibold text-primary">
|
||||
{tableName} (조인)
|
||||
</SelectLabel>
|
||||
{columns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.joinAlias}
|
||||
value={col.joinAlias}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.suggestedLabel || col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">카드 디스플레이 설정</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 테이블</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getSelectedTableDisplay()}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
|
||||
{/* 화면 기본 테이블 */}
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => handleTableSelect(screenTableName, true)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTableName === screenTableName && !config.useCustomTable
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-primary" />
|
||||
{allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* 전체 테이블 */}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter(t => t.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => handleTableSelect(table.tableName, false)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.useCustomTable && targetTableName === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{table.displayName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.useCustomTable && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
|
||||
{(currentTableColumns.length > 0 || loadingColumns) && (
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">컬럼 매핑</h5>
|
||||
|
||||
{(loadingEntityJoins || loadingColumns) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타이틀 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.titleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.titleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">서브타이틀 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.subtitleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.descriptionColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">이미지 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.imageColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.imageColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 동적 표시 컬럼 추가 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">표시 컬럼들</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDisplayColumn}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
+ 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{renderColumnSelect(
|
||||
column,
|
||||
(value) => updateDisplayColumn(index, value)
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisplayColumn(index)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
|
||||
"컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 스타일 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">카드 스타일</h5>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">한 행당 카드 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardsPerRow || 3}
|
||||
onChange={(e) => handleChange("cardsPerRow", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">카드 간격 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.cardSpacing || 16}
|
||||
onChange={(e) => handleChange("cardSpacing", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showTitle"
|
||||
checked={config.cardStyle?.showTitle ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showTitle", checked)}
|
||||
/>
|
||||
<Label htmlFor="showTitle" className="text-xs font-normal">
|
||||
타이틀 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showSubtitle"
|
||||
checked={config.cardStyle?.showSubtitle ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showSubtitle", checked)}
|
||||
/>
|
||||
<Label htmlFor="showSubtitle" className="text-xs font-normal">
|
||||
서브타이틀 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDescription"
|
||||
checked={config.cardStyle?.showDescription ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDescription", checked)}
|
||||
/>
|
||||
<Label htmlFor="showDescription" className="text-xs font-normal">
|
||||
설명 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showImage"
|
||||
checked={config.cardStyle?.showImage ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showImage", checked)}
|
||||
/>
|
||||
<Label htmlFor="showImage" className="text-xs font-normal">
|
||||
이미지 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showActions"
|
||||
checked={config.cardStyle?.showActions ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showActions", checked)}
|
||||
/>
|
||||
<Label htmlFor="showActions" className="text-xs font-normal">
|
||||
액션 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 개별 버튼 설정 */}
|
||||
{(config.cardStyle?.showActions ?? true) && (
|
||||
<div className="ml-5 space-y-2 border-l-2 border-muted pl-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showViewButton"
|
||||
checked={config.cardStyle?.showViewButton ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showViewButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showViewButton" className="text-xs font-normal">
|
||||
상세보기 버튼
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showEditButton"
|
||||
checked={config.cardStyle?.showEditButton ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showEditButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showEditButton" className="text-xs font-normal">
|
||||
편집 버튼
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDeleteButton"
|
||||
checked={config.cardStyle?.showDeleteButton ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDeleteButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showDeleteButton" className="text-xs font-normal">
|
||||
삭제 버튼
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명 최대 길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={config.cardStyle?.maxDescriptionLength || 100}
|
||||
onChange={(e) => handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">공통 설정</h5>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="disabled" className="text-xs font-normal">
|
||||
비활성화
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs font-normal">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { CardDisplayDefinition } from "./index";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
|
||||
/**
|
||||
* CardDisplay 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class CardDisplayRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = CardDisplayDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <CardDisplayComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getCardDisplayProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
CardDisplayRenderer.registerSelf();
|
||||
@@ -1,93 +0,0 @@
|
||||
# CardDisplay 컴포넌트
|
||||
|
||||
테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `card-display`
|
||||
- **카테고리**: display
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { CardDisplayComponent } from "@/lib/registry/components/card-display";
|
||||
|
||||
<CardDisplayComponent
|
||||
component={{
|
||||
id: "my-card-display",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<CardDisplayComponent
|
||||
component={{
|
||||
id: "sample-card-display",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-15
|
||||
- **CLI 명령어**: `node scripts/create-component.js card-display "카드 디스플레이" "테이블 데이터를 카드 형태로 표시하는 컴포넌트" display text`
|
||||
- **경로**: `lib/registry/components/card-display/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/card-display)
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
import { CardDisplayConfigPanel } from "./CardDisplayConfigPanel";
|
||||
import { CardDisplayConfig } from "./types";
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 정의
|
||||
* 테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
||||
*/
|
||||
export const CardDisplayDefinition = createComponentDefinition({
|
||||
id: "card-display",
|
||||
name: "카드 디스플레이",
|
||||
name_eng: "CardDisplay Component",
|
||||
description: "테이블 데이터를 카드 형태로 표시하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
web_type: "text",
|
||||
component: CardDisplayComponent,
|
||||
default_config: {
|
||||
cardsPerRow: 3, // 기본값 3 (한 행당 카드 수)
|
||||
cardSpacing: 16,
|
||||
cardStyle: {
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showDescription: true,
|
||||
showImage: false,
|
||||
showActions: true,
|
||||
maxDescriptionLength: 100,
|
||||
imagePosition: "top",
|
||||
imageSize: "medium",
|
||||
},
|
||||
columnMapping: {},
|
||||
dataSource: "table",
|
||||
staticData: [],
|
||||
},
|
||||
default_size: { width: 800, height: 400 },
|
||||
config_panel: CardDisplayConfigPanel,
|
||||
icon: "Grid3x3",
|
||||
tags: ["card", "display", "table", "grid"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation:
|
||||
"테이블 데이터를 카드 형태로 표시하는 컴포넌트입니다. 레이아웃과 다르게 컴포넌트로서 재사용 가능하며, 다양한 설정이 가능합니다.",
|
||||
hidden: true, // v2-card-display 사용으로 패널에서 숨김
|
||||
});
|
||||
|
||||
// 컴포넌트는 CardDisplayRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { CardDisplayConfig } from "./types";
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 카드 스타일 설정
|
||||
*/
|
||||
export interface CardStyleConfig {
|
||||
showTitle?: boolean;
|
||||
showSubtitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showImage?: boolean;
|
||||
maxDescriptionLength?: number;
|
||||
imagePosition?: "top" | "left" | "right";
|
||||
imageSize?: "small" | "medium" | "large";
|
||||
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
||||
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
||||
showEditButton?: boolean; // 편집 버튼 표시 여부
|
||||
showDeleteButton?: boolean; // 삭제 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 설정
|
||||
*/
|
||||
export interface ColumnMappingConfig {
|
||||
titleColumn?: string;
|
||||
subtitleColumn?: string;
|
||||
descriptionColumn?: string;
|
||||
imageColumn?: string;
|
||||
displayColumns?: string[];
|
||||
actionColumns?: string[]; // 액션 버튼으로 표시할 컬럼들
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface CardDisplayConfig extends ComponentConfig {
|
||||
// 카드 레이아웃 설정
|
||||
cardsPerRow?: number;
|
||||
cardSpacing?: number;
|
||||
|
||||
// 카드 스타일 설정
|
||||
cardStyle?: CardStyleConfig;
|
||||
|
||||
// 컬럼 매핑 설정
|
||||
columnMapping?: ColumnMappingConfig;
|
||||
|
||||
// 컴포넌트별 테이블 설정
|
||||
useCustomTable?: boolean;
|
||||
customTableName?: string;
|
||||
tableName?: string;
|
||||
isReadOnly?: boolean;
|
||||
|
||||
// 테이블 데이터 설정
|
||||
dataSource?: "static" | "table" | "api";
|
||||
tableId?: string;
|
||||
staticData?: any[];
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onCardClick?: (data: any) => void;
|
||||
onCardHover?: (data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface CardDisplayProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: CardDisplayConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -70,7 +70,6 @@ import "./button-primary/ButtonPrimaryRenderer";
|
||||
import "./text-display/TextDisplayRenderer";
|
||||
import "./divider-line/DividerLineRenderer";
|
||||
import "./table-list/TableListRenderer";
|
||||
import "./card-display/CardDisplayRenderer";
|
||||
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||
import "./numbering-rule/NumberingRuleRenderer";
|
||||
import "./table-search-widget";
|
||||
@@ -80,7 +79,6 @@ import "./section-card/SectionCardRenderer";
|
||||
import "./tabs/tabs-component";
|
||||
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||
import "./rack-structure/RackStructureRenderer";
|
||||
import "./pivot-grid/PivotGridRenderer";
|
||||
import "./aggregation-widget/AggregationWidgetRenderer";
|
||||
import "./repeat-container/RepeatContainerRenderer";
|
||||
|
||||
@@ -91,11 +89,9 @@ import "./v2-repeater/V2RepeaterRenderer";
|
||||
import "./v2-button-primary/ButtonPrimaryRenderer";
|
||||
import "./v2-split-panel-layout/SplitPanelLayoutRenderer";
|
||||
import "./v2-aggregation-widget/AggregationWidgetRenderer";
|
||||
import "./v2-card-display/CardDisplayRenderer";
|
||||
import "./v2-numbering-rule/NumberingRuleRenderer";
|
||||
import "./v2-table-list/TableListRenderer";
|
||||
import "./v2-text-display/TextDisplayRenderer";
|
||||
import "./v2-pivot-grid/PivotGridRenderer";
|
||||
import "./v2-divider-line/DividerLineRenderer";
|
||||
|
||||
// ============================================================
|
||||
@@ -124,7 +120,6 @@ import "./v2-table-search-widget";
|
||||
import "./v2-tabs-widget/tabs-component";
|
||||
import "./v2-category-manager/V2CategoryManagerRenderer";
|
||||
import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트
|
||||
import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블
|
||||
import "./domain/v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러
|
||||
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
# PivotGrid 컴포넌트 전체 구현 계획
|
||||
|
||||
## 개요
|
||||
DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현
|
||||
|
||||
## 현재 상태: ✅ 모든 기능 구현 완료!
|
||||
|
||||
---
|
||||
|
||||
## 구현된 기능 목록
|
||||
|
||||
### 1. 기본 피벗 테이블 ✅
|
||||
- [x] 피벗 테이블 렌더링
|
||||
- [x] 행/열 확장/축소
|
||||
- [x] 합계/소계 표시
|
||||
- [x] 전체 확장/축소 버튼
|
||||
|
||||
### 2. 필드 패널 (드래그앤드롭) ✅
|
||||
- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터)
|
||||
- [x] 각 영역에 배치된 필드 칩/태그 표시
|
||||
- [x] 필드 제거 버튼 (X)
|
||||
- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용)
|
||||
- [x] 영역 간 필드 이동
|
||||
- [x] 같은 영역 내 순서 변경
|
||||
- [x] 드래그 시 시각적 피드백
|
||||
|
||||
### 3. 필드 선택기 (모달) ✅
|
||||
- [x] 모달 열기/닫기
|
||||
- [x] 사용 가능한 필드 목록
|
||||
- [x] 필드 검색 기능
|
||||
- [x] 필드별 영역 선택 드롭다운
|
||||
- [x] 데이터 타입 아이콘 표시
|
||||
- [x] 집계 함수 선택 (데이터 영역)
|
||||
- [x] 표시 모드 선택 (데이터 영역)
|
||||
|
||||
### 4. 데이터 요약 (누계, % 모드) ✅
|
||||
- [x] 절대값 표시 (기본)
|
||||
- [x] 행 총계 대비 %
|
||||
- [x] 열 총계 대비 %
|
||||
- [x] 전체 총계 대비 %
|
||||
- [x] 행/열 방향 누계
|
||||
- [x] 이전 대비 차이
|
||||
- [x] 이전 대비 % 차이
|
||||
|
||||
### 5. 필터링 ✅
|
||||
- [x] 필터 팝업 컴포넌트 (FilterPopup)
|
||||
- [x] 값 검색 기능
|
||||
- [x] 체크박스 기반 값 선택
|
||||
- [x] 포함/제외 모드
|
||||
- [x] 전체 선택/해제
|
||||
- [x] 선택된 항목 수 표시
|
||||
|
||||
### 6. Drill Down ✅
|
||||
- [x] 셀 더블클릭 시 상세 데이터 모달
|
||||
- [x] 원본 데이터 테이블 표시
|
||||
- [x] 검색 기능
|
||||
- [x] 정렬 기능
|
||||
- [x] 페이지네이션
|
||||
- [x] CSV/Excel 내보내기
|
||||
|
||||
### 7. Virtual Scrolling ✅
|
||||
- [x] useVirtualScroll 훅 (행)
|
||||
- [x] useVirtualColumnScroll 훅 (열)
|
||||
- [x] useVirtual2DScroll 훅 (행+열)
|
||||
- [x] overscan 버퍼 지원
|
||||
|
||||
### 8. Excel 내보내기 ✅
|
||||
- [x] xlsx 라이브러리 사용
|
||||
- [x] 피벗 데이터 Excel 내보내기
|
||||
- [x] Drill Down 데이터 Excel 내보내기
|
||||
- [x] CSV 내보내기 (기본)
|
||||
- [x] 스타일링 (헤더, 데이터, 총계)
|
||||
- [x] 숫자 포맷
|
||||
|
||||
### 9. 차트 통합 ✅
|
||||
- [x] recharts 라이브러리 사용
|
||||
- [x] 막대 차트
|
||||
- [x] 누적 막대 차트
|
||||
- [x] 선 차트
|
||||
- [x] 영역 차트
|
||||
- [x] 파이 차트
|
||||
- [x] 범례 표시
|
||||
- [x] 커스텀 툴팁
|
||||
- [x] 차트 토글 버튼
|
||||
|
||||
### 10. 조건부 서식 (Conditional Formatting) ✅
|
||||
- [x] Color Scale (색상 그라데이션)
|
||||
- [x] Data Bar (데이터 막대)
|
||||
- [x] Icon Set (아이콘)
|
||||
- [x] Cell Value (조건 기반 스타일)
|
||||
- [x] ConfigPanel에서 설정 UI
|
||||
|
||||
### 11. 상태 저장/복원 ✅
|
||||
- [x] usePivotState 훅
|
||||
- [x] localStorage/sessionStorage 지원
|
||||
- [x] 자동 저장 (디바운스)
|
||||
|
||||
### 12. ConfigPanel 고도화 ✅
|
||||
- [x] 데이터 소스 설정 (테이블 선택)
|
||||
- [x] 필드별 영역 설정 (행, 열, 데이터, 필터)
|
||||
- [x] 총계 옵션 설정
|
||||
- [x] 스타일 설정 (테마, 교차 색상 등)
|
||||
- [x] 내보내기 설정 (Excel/CSV)
|
||||
- [x] 차트 설정 UI
|
||||
- [x] 필드 선택기 설정 UI
|
||||
- [x] 조건부 서식 설정 UI
|
||||
- [x] 크기 설정
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pivot-grid/
|
||||
├── components/
|
||||
│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭)
|
||||
│ ├── FieldChooser.tsx # 필드 선택기 모달
|
||||
│ ├── DrillDownModal.tsx # Drill Down 모달
|
||||
│ ├── FilterPopup.tsx # 필터 팝업
|
||||
│ ├── PivotChart.tsx # 차트 컴포넌트
|
||||
│ └── index.ts # 내보내기
|
||||
├── hooks/
|
||||
│ ├── useVirtualScroll.ts # 가상 스크롤 훅
|
||||
│ ├── usePivotState.ts # 상태 저장 훅
|
||||
│ └── index.ts # 내보내기
|
||||
├── utils/
|
||||
│ ├── aggregation.ts # 집계 함수
|
||||
│ ├── pivotEngine.ts # 피벗 엔진
|
||||
│ ├── exportExcel.ts # Excel 내보내기
|
||||
│ ├── conditionalFormat.ts # 조건부 서식
|
||||
│ └── index.ts # 내보내기
|
||||
├── types.ts # 타입 정의
|
||||
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||
├── PivotGridRenderer.tsx # 렌더러
|
||||
├── index.ts # 모듈 내보내기
|
||||
└── PLAN.md # 이 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 후순위 기능 (선택적)
|
||||
|
||||
다음 기능들은 필요 시 추가 구현 가능:
|
||||
|
||||
### 데이터 바인딩 확장
|
||||
- [ ] OLAP Data Source 연동 (복잡)
|
||||
- [ ] GraphQL 연동
|
||||
- [ ] 실시간 데이터 업데이트 (WebSocket)
|
||||
|
||||
### 고급 기능
|
||||
- [ ] 피벗 테이블 병합 (여러 데이터 소스)
|
||||
- [ ] 계산 필드 (커스텀 수식)
|
||||
- [ ] 데이터 정렬 옵션 강화
|
||||
- [ ] 그룹핑 옵션 (날짜 그룹핑 등)
|
||||
|
||||
---
|
||||
|
||||
## 완료일: 2026-01-08
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,822 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 설정 패널 - 간소화 버전
|
||||
*
|
||||
* 피벗 테이블 설정 방법:
|
||||
* 1. 테이블 선택
|
||||
* 2. 컬럼을 드래그하여 행/열/값 영역에 배치
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridComponentConfig,
|
||||
PivotFieldConfig,
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
FieldDataType,
|
||||
DateGroupInterval,
|
||||
} 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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Rows,
|
||||
Columns,
|
||||
Calculator,
|
||||
X,
|
||||
Plus,
|
||||
GripVertical,
|
||||
Table2,
|
||||
BarChart3,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_comment?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
column_comment?: string;
|
||||
}
|
||||
|
||||
interface PivotGridConfigPanelProps {
|
||||
config: PivotGridComponentConfig;
|
||||
onChange: (config: PivotGridComponentConfig) => void;
|
||||
}
|
||||
|
||||
// DB 타입을 FieldDataType으로 변환
|
||||
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 ColumnChipProps {
|
||||
column: ColumnInfo;
|
||||
isUsed: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ColumnChip: React.FC<ColumnChipProps> = ({ column, isUsed, onClick }) => {
|
||||
const dataType = mapDbTypeToFieldType(column.data_type);
|
||||
const typeColor = {
|
||||
number: "bg-primary/10 text-primary border-primary/20",
|
||||
string: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
date: "bg-purple-100 text-purple-700 border-purple-200",
|
||||
boolean: "bg-amber-100 text-orange-700 border-orange-200",
|
||||
}[dataType];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isUsed}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs border transition-all",
|
||||
isUsed
|
||||
? "bg-muted text-muted-foreground border-muted cursor-not-allowed opacity-50"
|
||||
: cn(typeColor, "hover:shadow-sm cursor-pointer")
|
||||
)}
|
||||
>
|
||||
<span className="font-medium truncate max-w-[120px]">
|
||||
{column.column_comment || column.column_name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 영역 드롭존 컴포넌트 ====================
|
||||
|
||||
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;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
|
||||
area,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
fields,
|
||||
columns,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onUpdateField,
|
||||
color,
|
||||
}) => {
|
||||
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", color)}>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium text-sm">{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-xs 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 bg-background rounded-md px-2 py-1.5 border"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="flex-1 text-xs font-medium truncate">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */}
|
||||
{(area === "row" || area === "column") && field.dataType === "date" && (
|
||||
<Select
|
||||
value={field.groupInterval || "__none__"}
|
||||
onValueChange={(v) => onUpdateField(idx, {
|
||||
groupInterval: v === "__none__" ? undefined : v as DateGroupInterval
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-xs">
|
||||
<SelectValue placeholder="그룹" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
<SelectItem value="year">년</SelectItem>
|
||||
<SelectItem value="quarter">분기</SelectItem>
|
||||
<SelectItem value="month">월</SelectItem>
|
||||
<SelectItem value="week">주</SelectItem>
|
||||
<SelectItem value="day">일</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="text-xs text-muted-foreground text-center py-2 border border-dashed rounded-md">
|
||||
아래에서 컬럼을 선택하세요
|
||||
</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="h-3 w-3 mr-1" />
|
||||
<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-muted-foreground text-xs">
|
||||
({mapDbTypeToFieldType(col.data_type)})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
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);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
const mappedTables: TableInfo[] = tableList.map((t: any) => ({
|
||||
table_name: t.table_name,
|
||||
table_comment: t.table_label || t.display_name || t.table_name,
|
||||
}));
|
||||
setTables(mappedTables);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} 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);
|
||||
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
|
||||
column_name: c.column_name,
|
||||
data_type: c.data_type || "text",
|
||||
// 라벨 우선순위: display_name > comment > column_label > column_name
|
||||
column_comment: c.display_name || c.comment || c.column_label || c.column_name,
|
||||
}));
|
||||
setColumns(mappedColumns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} 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) => {
|
||||
if (f.area === area && f.areaIndex === index) {
|
||||
return { ...f, ...updates };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
updateConfig({ fields: newFields });
|
||||
};
|
||||
|
||||
// 영역별 필드 가져오기
|
||||
const getFieldsByArea = (area: PivotAreaType) => {
|
||||
return (config.fields || [])
|
||||
.filter(f => f.area === area)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 사용 가이드 */}
|
||||
<div className="bg-primary/10 border border-primary/20 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5" />
|
||||
<div className="text-xs text-primary">
|
||||
<p className="font-medium mb-1">피벗 테이블 설정 방법</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5 text-primary">
|
||||
<li>데이터를 가져올 <strong>테이블</strong>을 선택하세요</li>
|
||||
<li><strong>행 그룹</strong>에 그룹화할 컬럼을 추가하세요 (예: 지역, 부서)</li>
|
||||
<li><strong>열 그룹</strong>에 가로로 펼칠 컬럼을 추가하세요 (예: 월, 분기)</li>
|
||||
<li><strong>값</strong>에 집계할 숫자 컬럼을 추가하세요 (예: 매출, 수량)</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 1: 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-sm font-semibold">STEP 1. 테이블 선택</Label>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={config.dataSource?.tableName || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({
|
||||
dataSource: {
|
||||
...config.dataSource,
|
||||
type: "table",
|
||||
tableName: v === "__none__" ? undefined : v,
|
||||
},
|
||||
fields: [], // 테이블 변경 시 필드 초기화
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_comment || table.table_name}</span>
|
||||
{table.table_comment && (
|
||||
<span className="text-muted-foreground text-xs">({table.table_name})</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* STEP 2: 필드 배치 */}
|
||||
{config.dataSource?.tableName && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-sm font-semibold">STEP 2. 필드 배치</Label>
|
||||
{loadingColumns && <span className="text-xs text-muted-foreground">(컬럼 로딩 중...)</span>}
|
||||
</div>
|
||||
|
||||
{/* 사용 가능한 컬럼 목록 */}
|
||||
{columns.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-lg p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">사용 가능한 컬럼</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{columns.map((col) => {
|
||||
const isUsed = (config.fields || []).some(f => f.field === col.column_name);
|
||||
return (
|
||||
<ColumnChip
|
||||
key={col.column_name}
|
||||
column={col}
|
||||
isUsed={isUsed}
|
||||
onClick={() => {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 영역별 드롭존 */}
|
||||
<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, updates) => handleUpdateField("row", idx, updates)}
|
||||
color="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, updates) => handleUpdateField("column", idx, updates)}
|
||||
color="border-primary/20 bg-primary/10/50"
|
||||
/>
|
||||
|
||||
<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, updates) => handleUpdateField("data", idx, updates)}
|
||||
color="border-amber-200 bg-amber-50/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 설정 토글 */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>고급 설정</span>
|
||||
</div>
|
||||
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
{/* 표시 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">표시 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">행 총계 위치</Label>
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 총계 위치</Label>
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 소계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">줄무늬</Label>
|
||||
<Switch
|
||||
checked={config.style?.alternateRowColors !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...config.style, alternateRowColors: v } as any })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">셀 병합</Label>
|
||||
<Switch
|
||||
checked={config.style?.mergeCells === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...config.style, mergeCells: v } as any })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">CSV 내보내기</Label>
|
||||
<Switch
|
||||
checked={config.exportConfig?.excel === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">상태 저장</Label>
|
||||
<Switch
|
||||
checked={(config as any).saveState === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ saveState: v } as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">크기 설정</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
value={config.height || ""}
|
||||
onChange={(e) => updateConfig({ height: e.target.value })}
|
||||
placeholder="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 className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">조건부 서식</Label>
|
||||
<div className="space-y-2">
|
||||
{(config.style?.conditionalFormats || []).map((rule, index) => (
|
||||
<div key={rule.id} className="flex items-center gap-2 p-2 rounded-md bg-muted/30">
|
||||
<Select
|
||||
value={rule.type}
|
||||
onValueChange={(v) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, type: v as any };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } as any });
|
||||
}}
|
||||
>
|
||||
<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 } as any });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
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 } as any });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
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 } as any });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
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 } as any });
|
||||
}}
|
||||
>
|
||||
<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="h-6 w-6 ml-auto"
|
||||
onClick={() => {
|
||||
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } as any });
|
||||
}}
|
||||
>
|
||||
<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 } as any });
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조건부 서식 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotGridConfigPanel;
|
||||
@@ -1,384 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { PivotGridComponent } from "./PivotGridComponent";
|
||||
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||
import { PivotFieldConfig } from "./types";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ==================== 에러 경계 ====================
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class PivotGridErrorBoundary extends Component<
|
||||
{ children: ReactNode; onReset?: () => void },
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: { children: ReactNode; onReset?: () => void }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("🔴 [PivotGrid] 렌더링 에러:", error);
|
||||
console.error("🔴 [PivotGrid] 에러 정보:", errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center border border-destructive/50 rounded-lg bg-destructive/5">
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||||
<h3 className="text-sm font-medium text-destructive mb-1">
|
||||
피벗 그리드 오류
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3 max-w-md">
|
||||
{this.state.error?.message || "알 수 없는 오류가 발생했습니다."}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={this.handleReset}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 샘플 데이터 (미리보기용) ====================
|
||||
|
||||
const SAMPLE_DATA = [
|
||||
{ region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 },
|
||||
];
|
||||
|
||||
const SAMPLE_FIELDS: PivotFieldConfig[] = [
|
||||
{
|
||||
field: "region",
|
||||
caption: "지역",
|
||||
area: "row",
|
||||
areaIndex: 0,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "product",
|
||||
caption: "제품",
|
||||
area: "row",
|
||||
areaIndex: 1,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "quarter",
|
||||
caption: "분기",
|
||||
area: "column",
|
||||
areaIndex: 0,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "sales",
|
||||
caption: "매출",
|
||||
area: "data",
|
||||
areaIndex: 0,
|
||||
dataType: "number",
|
||||
summaryType: "sum",
|
||||
format: { type: "number", precision: 0 },
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입)
|
||||
*/
|
||||
const PivotGridWrapper: React.FC<any> = (props) => {
|
||||
// 컴포넌트 설정에서 값 추출
|
||||
const componentConfig = props.componentConfig || props.config || {};
|
||||
const configFields = componentConfig.fields || props.fields;
|
||||
const configData = props.data;
|
||||
|
||||
// 🆕 테이블에서 데이터 자동 로딩
|
||||
const [loadedData, setLoadedData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTableData = async () => {
|
||||
const tableName = componentConfig.dataSource?.tableName;
|
||||
|
||||
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음
|
||||
if (configData || !tableName || props.isDesignMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await dataApi.getTableData(tableName, {
|
||||
page: 1,
|
||||
size: 10000, // 피벗 분석용 대량 데이터
|
||||
});
|
||||
|
||||
// dataApi.getTableData는 { data, total, page, size, totalPages } 구조
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setLoadedData(response.data);
|
||||
} else {
|
||||
console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음");
|
||||
setLoadedData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [PivotGrid] 데이터 로딩 에러:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTableData();
|
||||
}, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]);
|
||||
|
||||
// 디자인 모드 판단:
|
||||
// 1. isDesignMode === true
|
||||
// 2. isInteractive === false (편집 모드)
|
||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||
|
||||
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
|
||||
const actualData = configData || loadedData;
|
||||
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
|
||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||
|
||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||
const usePreviewData = isDesignMode || (!hasValidData && !isLoading);
|
||||
|
||||
// 최종 데이터/필드 결정
|
||||
const finalData = usePreviewData ? SAMPLE_DATA : actualData;
|
||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||
const finalTitle = usePreviewData
|
||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||
: (componentConfig.title || props.title);
|
||||
|
||||
// 총계 설정
|
||||
const totalsConfig = componentConfig.totals || props.totals || {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
};
|
||||
|
||||
// 🆕 로딩 중 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함
|
||||
return (
|
||||
<PivotGridErrorBoundary>
|
||||
<PivotGridComponent
|
||||
title={finalTitle}
|
||||
data={finalData}
|
||||
fields={finalFields}
|
||||
totals={totalsConfig}
|
||||
style={componentConfig.style || props.style}
|
||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||
chart={componentConfig.chart || props.chart}
|
||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||
height="100%"
|
||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||
onCellClick={props.onCellClick}
|
||||
onCellDoubleClick={props.onCellDoubleClick}
|
||||
onFieldDrop={props.onFieldDrop}
|
||||
onExpandChange={props.onExpandChange}
|
||||
/>
|
||||
</PivotGridErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PivotGrid 컴포넌트 정의
|
||||
*/
|
||||
const PivotGridDefinition = createComponentDefinition({
|
||||
id: "pivot-grid",
|
||||
name: "피벗 그리드",
|
||||
name_eng: "PivotGrid Component",
|
||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
web_type: "text",
|
||||
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
|
||||
default_config: {
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "",
|
||||
},
|
||||
fields: SAMPLE_FIELDS,
|
||||
// 미리보기용 샘플 데이터
|
||||
sampleData: SAMPLE_DATA,
|
||||
totals: {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
},
|
||||
style: {
|
||||
theme: "default",
|
||||
headerStyle: "default",
|
||||
cellPadding: "normal",
|
||||
borderStyle: "light",
|
||||
alternateRowColors: true,
|
||||
highlightTotals: true,
|
||||
},
|
||||
allowExpandAll: true,
|
||||
exportConfig: {
|
||||
excel: true,
|
||||
},
|
||||
height: "400px",
|
||||
},
|
||||
default_size: { width: 800, height: 500 },
|
||||
config_panel: PivotGridConfigPanel,
|
||||
icon: "BarChart3",
|
||||
tags: ["피벗", "분석", "집계", "그리드", "데이터"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
// hidden: true, // v2-pivot-grid 사용으로 패널에서 숨김
|
||||
});
|
||||
|
||||
/**
|
||||
* PivotGrid 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = PivotGridDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const props = this.props as any;
|
||||
|
||||
// 컴포넌트 설정에서 값 추출
|
||||
const componentConfig = props.componentConfig || props.config || {};
|
||||
const configFields = componentConfig.fields || props.fields;
|
||||
const configData = props.data;
|
||||
|
||||
// 디자인 모드 판단:
|
||||
// 1. isDesignMode === true
|
||||
// 2. isInteractive === false (편집 모드)
|
||||
// 3. 데이터가 없는 경우
|
||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
|
||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||
|
||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||
const usePreviewData = isDesignMode || !hasValidData;
|
||||
|
||||
// 최종 데이터/필드 결정
|
||||
const finalData = usePreviewData ? SAMPLE_DATA : configData;
|
||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||
const finalTitle = usePreviewData
|
||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||
: (componentConfig.title || props.title);
|
||||
|
||||
// 총계 설정
|
||||
const totalsConfig = componentConfig.totals || props.totals || {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<PivotGridComponent
|
||||
title={finalTitle}
|
||||
data={finalData}
|
||||
fields={finalFields}
|
||||
totals={totalsConfig}
|
||||
style={componentConfig.style || props.style}
|
||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||
chart={componentConfig.chart || props.chart}
|
||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||
height="100%"
|
||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||
onCellClick={props.onCellClick}
|
||||
onCellDoubleClick={props.onCellDoubleClick}
|
||||
onFieldDrop={props.onFieldDrop}
|
||||
onExpandChange={props.onExpandChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
PivotGridRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
PivotGridRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("❌ PivotGrid 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
# PivotGrid 컴포넌트
|
||||
|
||||
다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 다차원 데이터 배치
|
||||
|
||||
- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시)
|
||||
- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기)
|
||||
- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량)
|
||||
- **필터 영역(Filter Area)**: 전체 데이터 필터링
|
||||
|
||||
### 2. 집계 함수
|
||||
|
||||
| 함수 | 설명 | 사용 예 |
|
||||
|------|------|---------|
|
||||
| `sum` | 합계 | 매출 합계 |
|
||||
| `count` | 개수 | 건수 |
|
||||
| `avg` | 평균 | 평균 단가 |
|
||||
| `min` | 최소값 | 최저가 |
|
||||
| `max` | 최대값 | 최고가 |
|
||||
| `countDistinct` | 고유값 개수 | 거래처 수 |
|
||||
|
||||
### 3. 날짜 그룹화
|
||||
|
||||
날짜 필드를 다양한 단위로 그룹화할 수 있습니다:
|
||||
|
||||
- `year`: 연도별
|
||||
- `quarter`: 분기별
|
||||
- `month`: 월별
|
||||
- `week`: 주별
|
||||
- `day`: 일별
|
||||
|
||||
### 4. 드릴다운
|
||||
|
||||
계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다.
|
||||
|
||||
### 5. 총합계/소계
|
||||
|
||||
- 행 총합계 (Row Grand Total)
|
||||
- 열 총합계 (Column Grand Total)
|
||||
- 행 소계 (Row Subtotal)
|
||||
- 열 소계 (Column Subtotal)
|
||||
|
||||
### 6. 내보내기
|
||||
|
||||
CSV 형식으로 데이터를 내보낼 수 있습니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용
|
||||
|
||||
```tsx
|
||||
import { PivotGridComponent } from "@/lib/registry/components/pivot-grid";
|
||||
|
||||
const salesData = [
|
||||
{ region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 },
|
||||
{ region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 },
|
||||
// ...
|
||||
];
|
||||
|
||||
<PivotGridComponent
|
||||
title="매출 분석"
|
||||
data={salesData}
|
||||
fields={[
|
||||
{ field: "region", caption: "지역", area: "row", areaIndex: 0 },
|
||||
{ field: "city", caption: "도시", area: "row", areaIndex: 1 },
|
||||
{ field: "year", caption: "연도", area: "column", areaIndex: 0 },
|
||||
{ field: "quarter", caption: "분기", area: "column", areaIndex: 1 },
|
||||
{ field: "amount", caption: "매출액", area: "data", summaryType: "sum" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 날짜 그룹화
|
||||
|
||||
```tsx
|
||||
<PivotGridComponent
|
||||
data={orderData}
|
||||
fields={[
|
||||
{ field: "customer", caption: "거래처", area: "row" },
|
||||
{
|
||||
field: "orderDate",
|
||||
caption: "주문일",
|
||||
area: "column",
|
||||
dataType: "date",
|
||||
groupInterval: "month", // 월별 그룹화
|
||||
},
|
||||
{ field: "totalAmount", caption: "주문금액", area: "data", summaryType: "sum" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 포맷 설정
|
||||
|
||||
```tsx
|
||||
<PivotGridComponent
|
||||
data={salesData}
|
||||
fields={[
|
||||
{ field: "region", caption: "지역", area: "row" },
|
||||
{ field: "year", caption: "연도", area: "column" },
|
||||
{
|
||||
field: "amount",
|
||||
caption: "매출액",
|
||||
area: "data",
|
||||
summaryType: "sum",
|
||||
format: {
|
||||
type: "currency",
|
||||
prefix: "₩",
|
||||
thousandSeparator: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "ratio",
|
||||
caption: "비율",
|
||||
area: "data",
|
||||
summaryType: "avg",
|
||||
format: {
|
||||
type: "percent",
|
||||
precision: 1,
|
||||
suffix: "%",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 화면 관리에서 사용
|
||||
|
||||
설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다.
|
||||
|
||||
```tsx
|
||||
import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid";
|
||||
|
||||
<PivotGridRenderer
|
||||
id="pivot1"
|
||||
config={{
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "sales_data",
|
||||
},
|
||||
fields: [...],
|
||||
totals: {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
},
|
||||
exportConfig: {
|
||||
excel: true,
|
||||
},
|
||||
}}
|
||||
autoFilter={{ companyCode: "COMPANY_A" }}
|
||||
/>
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### PivotGridProps
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `string` | - | 피벗 테이블 제목 |
|
||||
| `data` | `any[]` | `[]` | 원본 데이터 배열 |
|
||||
| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 |
|
||||
| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 |
|
||||
| `style` | `PivotStyleConfig` | - | 스타일 설정 |
|
||||
| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 |
|
||||
| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 |
|
||||
| `height` | `string | number` | `"auto"` | 높이 |
|
||||
| `maxHeight` | `string` | - | 최대 높이 |
|
||||
|
||||
### PivotFieldConfig
|
||||
|
||||
| 속성 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `field` | `string` | O | 데이터 필드명 |
|
||||
| `caption` | `string` | O | 표시 라벨 |
|
||||
| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 |
|
||||
| `areaIndex` | `number` | - | 영역 내 순서 |
|
||||
| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 |
|
||||
| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) |
|
||||
| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 |
|
||||
| `format` | `PivotFieldFormat` | - | 값 포맷 |
|
||||
| `visible` | `boolean` | - | 표시 여부 |
|
||||
|
||||
### PivotTotalsConfig
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 |
|
||||
| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 |
|
||||
| `showRowTotals` | `boolean` | `true` | 행 소계 표시 |
|
||||
| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pivot-grid/
|
||||
├── index.ts # 모듈 진입점
|
||||
├── types.ts # 타입 정의
|
||||
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||
├── PivotGridRenderer.tsx # 화면 관리 렌더러
|
||||
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||
├── README.md # 문서
|
||||
└── utils/
|
||||
├── index.ts # 유틸리티 모듈 진입점
|
||||
├── aggregation.ts # 집계 함수
|
||||
└── pivotEngine.ts # 피벗 데이터 처리 엔진
|
||||
```
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
### 1. 매출 분석
|
||||
|
||||
지역별/기간별/제품별 매출 현황을 분석합니다.
|
||||
|
||||
### 2. 재고 현황
|
||||
|
||||
창고별/품목별 재고 수량을 한눈에 파악합니다.
|
||||
|
||||
### 3. 생산 실적
|
||||
|
||||
생산라인별/일자별 생산량을 분석합니다.
|
||||
|
||||
### 4. 비용 분석
|
||||
|
||||
부서별/계정별 비용을 집계하여 분석합니다.
|
||||
|
||||
### 5. 수주 현황
|
||||
|
||||
거래처별/품목별/월별 수주 현황을 분석합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요.
|
||||
2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다.
|
||||
3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요.
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 컨텍스트 메뉴 컴포넌트
|
||||
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
ArrowUpAZ,
|
||||
ArrowDownAZ,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { PivotFieldConfig, AggregationType } from "../types";
|
||||
|
||||
interface PivotContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
// 현재 컨텍스트 정보
|
||||
cellType: "header" | "data" | "rowHeader" | "columnHeader";
|
||||
field?: PivotFieldConfig;
|
||||
rowPath?: string[];
|
||||
columnPath?: string[];
|
||||
value?: any;
|
||||
// 콜백
|
||||
onSort?: (field: string, direction: "asc" | "desc") => void;
|
||||
onFilter?: (field: string) => void;
|
||||
onExpand?: (path: string[]) => void;
|
||||
onCollapse?: (path: string[]) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
onCopy?: (value: any) => void;
|
||||
onHideField?: (field: string) => void;
|
||||
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
|
||||
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
|
||||
}
|
||||
|
||||
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
|
||||
children,
|
||||
cellType,
|
||||
field,
|
||||
rowPath,
|
||||
columnPath,
|
||||
value,
|
||||
onSort,
|
||||
onFilter,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
onCopy,
|
||||
onHideField,
|
||||
onChangeSummary,
|
||||
onDrillDown,
|
||||
}) => {
|
||||
const handleCopy = () => {
|
||||
if (value !== undefined && value !== null) {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
onCopy?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{/* 정렬 옵션 (헤더에서만) */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
정렬
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
오름차순
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
|
||||
<ArrowDownAZ className="mr-2 h-4 w-4" />
|
||||
내림차순
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확장/축소 옵션 */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && (
|
||||
<>
|
||||
{rowPath && rowPath.length > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
축소
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={onExpandAll}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
전체 확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onCollapseAll}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
전체 축소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필터 옵션 */}
|
||||
{field && onFilter && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onFilter(field.field)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
필터
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 집계 함수 변경 (데이터 필드에서만) */}
|
||||
{cellType === "data" && field && onChangeSummary && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
집계 함수
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "sum")}
|
||||
>
|
||||
합계
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "count")}
|
||||
>
|
||||
개수
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "avg")}
|
||||
>
|
||||
평균
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "min")}
|
||||
>
|
||||
최소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "max")}
|
||||
>
|
||||
최대
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 드릴다운 (데이터 셀에서만) */}
|
||||
{cellType === "data" && rowPath && columnPath && onDrillDown && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세 데이터 보기
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필드 숨기기 */}
|
||||
{field && onHideField && (
|
||||
<ContextMenuItem onClick={() => onHideField(field.field)}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
필드 숨기기
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{/* 복사 */}
|
||||
<ContextMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotContextMenu;
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DrillDownModal 컴포넌트
|
||||
* 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotCellData, PivotFieldConfig } from "../types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface DrillDownModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cellData: PivotCellData | null;
|
||||
data: any[]; // 전체 원본 데이터
|
||||
fields: PivotFieldConfig[];
|
||||
rowFields: PivotFieldConfig[];
|
||||
columnFields: PivotFieldConfig[];
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const DrillDownModal: React.FC<DrillDownModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
cellData,
|
||||
data,
|
||||
fields,
|
||||
rowFields,
|
||||
columnFields,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
|
||||
|
||||
// 드릴다운 데이터 필터링
|
||||
const filteredData = useMemo(() => {
|
||||
if (!cellData || !data) return [];
|
||||
|
||||
// 행/열 경로에 해당하는 데이터 필터링
|
||||
let result = data.filter((row) => {
|
||||
// 행 경로 매칭
|
||||
for (let i = 0; i < cellData.rowPath.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (field && String(row[field.field]) !== cellData.rowPath[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 열 경로 매칭
|
||||
for (let i = 0; i < cellData.columnPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (field && String(row[field.field]) !== cellData.columnPath[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 검색 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((row) =>
|
||||
Object.values(row).some((val) =>
|
||||
String(val).toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortConfig) {
|
||||
result = [...result].sort((a, b) => {
|
||||
const aVal = a[sortConfig.field];
|
||||
const bVal = b[sortConfig.field];
|
||||
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
comparison = aVal - bVal;
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return sortConfig.direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, currentPage, pageSize]);
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
const displayColumns = useMemo(() => {
|
||||
// 모든 필드의 field명 수집
|
||||
const fieldNames = new Set<string>();
|
||||
|
||||
// fields에서 가져오기
|
||||
fields.forEach((f) => fieldNames.add(f.field));
|
||||
|
||||
// 데이터에서 추가 컬럼 가져오기
|
||||
if (data.length > 0) {
|
||||
Object.keys(data[0]).forEach((key) => fieldNames.add(key));
|
||||
}
|
||||
|
||||
return Array.from(fieldNames).map((fieldName) => {
|
||||
const fieldConfig = fields.find((f) => f.field === fieldName);
|
||||
return {
|
||||
field: fieldName,
|
||||
caption: fieldConfig?.caption || fieldName,
|
||||
dataType: fieldConfig?.dataType || "string",
|
||||
};
|
||||
});
|
||||
}, [fields, data]);
|
||||
|
||||
// 정렬 토글
|
||||
const handleSort = (field: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (!prev || prev.field !== field) {
|
||||
return { field, direction: "asc" };
|
||||
}
|
||||
if (prev.direction === "asc") {
|
||||
return { field, direction: "desc" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
// CSV 내보내기
|
||||
const handleExportCSV = () => {
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
const headers = displayColumns.map((c) => c.caption);
|
||||
const rows = filteredData.map((row) =>
|
||||
displayColumns.map((c) => {
|
||||
const val = row[c.field];
|
||||
if (val === null || val === undefined) return "";
|
||||
if (typeof val === "string" && val.includes(",")) {
|
||||
return `"${val}"`;
|
||||
}
|
||||
return String(val);
|
||||
})
|
||||
);
|
||||
|
||||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
||||
|
||||
const blob = new Blob(["\uFEFF" + csv], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// 페이지 변경
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
};
|
||||
|
||||
// 경로 표시
|
||||
const pathDisplay = cellData
|
||||
? [
|
||||
...(cellData.rowPath.length > 0
|
||||
? [`행: ${cellData.rowPath.join(" > ")}`]
|
||||
: []),
|
||||
...(cellData.columnPath.length > 0
|
||||
? [`열: ${cellData.columnPath.join(" > ")}`]
|
||||
: []),
|
||||
].join(" | ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>상세 데이터</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pathDisplay || "선택한 셀의 원본 데이터"}
|
||||
<span className="ml-2 text-primary font-medium">
|
||||
({filteredData.length}건)
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex items-center gap-2 py-2 border-b border-border">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10개씩</SelectItem>
|
||||
<SelectItem value="20">20개씩</SelectItem>
|
||||
<SelectItem value="50">50개씩</SelectItem>
|
||||
<SelectItem value="100">100개씩</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredData.length === 0}
|
||||
className="h-9"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<ScrollArea className="flex-1 -mx-6">
|
||||
<div className="px-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{displayColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.field}
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort(col.field)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{col.caption}</span>
|
||||
{sortConfig?.field === col.field ? (
|
||||
sortConfig.direction === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={displayColumns.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{displayColumns.map((col) => (
|
||||
<TableCell
|
||||
key={col.field}
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
col.dataType === "number" && "text-right tabular-nums"
|
||||
)}
|
||||
>
|
||||
{formatCellValue(row[col.field], col.dataType)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(currentPage - 1) * pageSize + 1} -{" "}
|
||||
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
|
||||
{filteredData.length}건
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="px-3 text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function formatCellValue(value: any, dataType: string): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
if (dataType === "number") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return String(value);
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
if (dataType === "date") {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toLocaleDateString("ko-KR");
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 원본 반환
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default DrillDownModal;
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FieldChooser 컴포넌트
|
||||
* 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Columns,
|
||||
Rows,
|
||||
BarChart3,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Minus,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
ToggleLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface AvailableField {
|
||||
field: string;
|
||||
caption: string;
|
||||
dataType: "string" | "number" | "date" | "boolean";
|
||||
isSelected: boolean;
|
||||
currentArea?: PivotAreaType;
|
||||
}
|
||||
|
||||
interface FieldChooserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
availableFields: AvailableField[];
|
||||
selectedFields: PivotFieldConfig[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
}
|
||||
|
||||
// ==================== 영역 설정 ====================
|
||||
|
||||
const AREA_OPTIONS: {
|
||||
value: PivotAreaType | "none";
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ value: "none", label: "사용 안함", icon: <Minus className="h-3.5 w-3.5" /> },
|
||||
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
|
||||
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
|
||||
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
|
||||
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [
|
||||
{ value: "sum", label: "합계" },
|
||||
{ value: "count", label: "개수" },
|
||||
{ value: "avg", label: "평균" },
|
||||
{ value: "min", label: "최소" },
|
||||
{ value: "max", label: "최대" },
|
||||
{ value: "countDistinct", label: "고유 개수" },
|
||||
];
|
||||
|
||||
const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
||||
{ value: "absoluteValue", label: "절대값" },
|
||||
{ value: "percentOfRowTotal", label: "행 총계 %" },
|
||||
{ value: "percentOfColumnTotal", label: "열 총계 %" },
|
||||
{ value: "percentOfGrandTotal", label: "전체 총계 %" },
|
||||
{ value: "runningTotalByRow", label: "행 누계" },
|
||||
{ value: "runningTotalByColumn", label: "열 누계" },
|
||||
{ value: "differenceFromPrevious", label: "이전 대비 차이" },
|
||||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||
];
|
||||
|
||||
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "그룹 없음" },
|
||||
{ value: "year", label: "년" },
|
||||
{ value: "quarter", label: "분기" },
|
||||
{ value: "month", label: "월" },
|
||||
{ value: "week", label: "주" },
|
||||
{ value: "day", label: "일" },
|
||||
];
|
||||
|
||||
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
string: <Type className="h-3.5 w-3.5" />,
|
||||
number: <Hash className="h-3.5 w-3.5" />,
|
||||
date: <Calendar className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
// ==================== 필드 아이템 ====================
|
||||
|
||||
interface FieldItemProps {
|
||||
field: AvailableField;
|
||||
config?: PivotFieldConfig;
|
||||
onAreaChange: (area: PivotAreaType | "none") => void;
|
||||
onSummaryChange?: (summary: AggregationType) => void;
|
||||
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
|
||||
}
|
||||
|
||||
const FieldItem: React.FC<FieldItemProps> = ({
|
||||
field,
|
||||
config,
|
||||
onAreaChange,
|
||||
onSummaryChange,
|
||||
onDisplayModeChange,
|
||||
}) => {
|
||||
const currentArea = config?.area || "none";
|
||||
const isSelected = currentArea !== "none";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-md border",
|
||||
"transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-primary/30"
|
||||
: "bg-background border-border hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{/* 데이터 타입 아이콘 */}
|
||||
<div className="text-muted-foreground">
|
||||
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* 필드명 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{field.caption}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{field.field}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영역 선택 */}
|
||||
<Select
|
||||
value={currentArea}
|
||||
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AREA_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 집계 함수 선택 (데이터 영역인 경우) */}
|
||||
{currentArea === "data" && onSummaryChange && (
|
||||
<Select
|
||||
value={config?.summaryType || "sum"}
|
||||
onValueChange={(value) => onSummaryChange(value as AggregationType)}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUMMARY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 표시 모드 선택 (데이터 영역인 경우) */}
|
||||
{currentArea === "data" && onDisplayModeChange && (
|
||||
<Select
|
||||
value={config?.summaryDisplayMode || "absoluteValue"}
|
||||
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISPLAY_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
availableFields,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">(
|
||||
"all"
|
||||
);
|
||||
|
||||
// 필터링된 필드 목록
|
||||
const filteredFields = useMemo(() => {
|
||||
let result = availableFields;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
f.caption.toLowerCase().includes(query) ||
|
||||
f.field.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 상태 필터
|
||||
if (filterType === "selected") {
|
||||
result = result.filter((f) =>
|
||||
selectedFields.some((sf) => sf.field === f.field && sf.visible !== false)
|
||||
);
|
||||
} else if (filterType === "unselected") {
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
!selectedFields.some(
|
||||
(sf) => sf.field === f.field && sf.visible !== false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [availableFields, selectedFields, searchQuery, filterType]);
|
||||
|
||||
// 필드 영역 변경
|
||||
const handleAreaChange = (
|
||||
field: AvailableField,
|
||||
area: PivotAreaType | "none"
|
||||
) => {
|
||||
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
||||
|
||||
if (area === "none") {
|
||||
// 필드 완전 제거 (visible: false 대신 배열에서 제거)
|
||||
if (existingConfig) {
|
||||
const newFields = selectedFields.filter((f) => f.field !== field.field);
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
} else {
|
||||
// 필드 추가 또는 영역 변경
|
||||
if (existingConfig) {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field
|
||||
? { ...f, area, visible: true }
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
} else {
|
||||
// 새 필드 추가
|
||||
const newField: PivotFieldConfig = {
|
||||
field: field.field,
|
||||
caption: field.caption,
|
||||
area,
|
||||
dataType: field.dataType,
|
||||
visible: true,
|
||||
summaryType: area === "data" ? "sum" : undefined,
|
||||
areaIndex: selectedFields.filter((f) => f.area === area).length,
|
||||
};
|
||||
onFieldsChange([...selectedFields, newField]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 집계 함수 변경
|
||||
const handleSummaryChange = (
|
||||
field: AvailableField,
|
||||
summaryType: AggregationType
|
||||
) => {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, summaryType } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 표시 모드 변경
|
||||
const handleDisplayModeChange = (
|
||||
field: AvailableField,
|
||||
displayMode: SummaryDisplayMode
|
||||
) => {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 모든 필드 선택 해제
|
||||
const handleClearAll = () => {
|
||||
const newFields = selectedFields.map((f) => ({ ...f, visible: false }));
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
const visible = selectedFields.filter((f) => f.visible !== false);
|
||||
return {
|
||||
total: availableFields.length,
|
||||
selected: visible.length,
|
||||
filter: visible.filter((f) => f.area === "filter").length,
|
||||
row: visible.filter((f) => f.area === "row").length,
|
||||
column: visible.filter((f) => f.area === "column").length,
|
||||
data: visible.filter((f) => f.area === "data").length,
|
||||
};
|
||||
}, [availableFields, selectedFields]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>필드 선택기</DialogTitle>
|
||||
<DialogDescription>
|
||||
피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
|
||||
<span>전체: {stats.total}</span>
|
||||
<span className="text-primary font-medium">
|
||||
선택됨: {stats.selected}
|
||||
</span>
|
||||
<span>필터: {stats.filter}</span>
|
||||
<span>행: {stats.row}</span>
|
||||
<span>열: {stats.column}</span>
|
||||
<span>데이터: {stats.data}</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filterType}
|
||||
onValueChange={(v) =>
|
||||
setFilterType(v as "all" | "selected" | "unselected")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="selected">선택됨</SelectItem>
|
||||
<SelectItem value="unselected">미선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-9"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<ScrollArea className="flex-1 -mx-6 px-6 max-h-[40vh] overflow-y-auto">
|
||||
<div className="space-y-2 py-2">
|
||||
{filteredFields.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filteredFields.map((field) => {
|
||||
const config = selectedFields.find(
|
||||
(f) => f.field === field.field && f.visible !== false
|
||||
);
|
||||
return (
|
||||
<FieldItem
|
||||
key={field.field}
|
||||
field={field}
|
||||
config={config}
|
||||
onAreaChange={(area) => handleAreaChange(field, area)}
|
||||
onSummaryChange={
|
||||
config?.area === "data"
|
||||
? (summary) => handleSummaryChange(field, summary)
|
||||
: undefined
|
||||
}
|
||||
onDisplayModeChange={
|
||||
config?.area === "data"
|
||||
? (mode) => handleDisplayModeChange(field, mode)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldChooser;
|
||||
|
||||
@@ -1,828 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FieldPanel 컴포넌트
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터)
|
||||
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig, PivotAreaType } from "../types";
|
||||
import {
|
||||
X,
|
||||
Filter,
|
||||
Columns,
|
||||
Rows,
|
||||
BarChart3,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
FilterX,
|
||||
LayoutGrid,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface FieldPanelProps {
|
||||
fields: PivotFieldConfig[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
onFieldRemove?: (field: PivotFieldConfig) => void;
|
||||
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
/** 초기 필드 설정 (필드 배치 초기화용) */
|
||||
initialFields?: PivotFieldConfig[];
|
||||
}
|
||||
|
||||
interface FieldChipProps {
|
||||
field: PivotFieldConfig;
|
||||
onRemove: () => void;
|
||||
onSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
}
|
||||
|
||||
interface DroppableAreaProps {
|
||||
area: PivotAreaType;
|
||||
fields: PivotFieldConfig[];
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
onFieldRemove: (field: PivotFieldConfig) => void;
|
||||
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 영역 설정 ====================
|
||||
|
||||
const AREA_CONFIG: Record<
|
||||
PivotAreaType,
|
||||
{ title: string; icon: React.ReactNode; color: string }
|
||||
> = {
|
||||
filter: {
|
||||
title: "필터",
|
||||
icon: <Filter className="h-3.5 w-3.5" />,
|
||||
color: "bg-amber-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800",
|
||||
},
|
||||
column: {
|
||||
title: "열",
|
||||
icon: <Columns className="h-3.5 w-3.5" />,
|
||||
color: "bg-primary/10 border-primary/20 dark:bg-primary/10 dark:border-primary/30",
|
||||
},
|
||||
row: {
|
||||
title: "행",
|
||||
icon: <Rows className="h-3.5 w-3.5" />,
|
||||
color: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800",
|
||||
},
|
||||
data: {
|
||||
title: "데이터",
|
||||
icon: <BarChart3 className="h-3.5 w-3.5" />,
|
||||
color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 필드 칩 (드래그 가능) ====================
|
||||
|
||||
const SortableFieldChip: React.FC<FieldChipProps> = ({
|
||||
field,
|
||||
onRemove,
|
||||
onSettingsChange,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `${field.area}-${field.field}` });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// 필터 적용 여부 확인
|
||||
const hasFilter = field.filterValues && field.filterValues.length > 0;
|
||||
const filterCount = field.filterValues?.length || 0;
|
||||
|
||||
// 그룹화 상태 확인
|
||||
const hasGrouping = field.groupInterval && field.dataType === "date";
|
||||
const groupLabels: Record<string, string> = {
|
||||
year: "연도",
|
||||
quarter: "분기",
|
||||
month: "월",
|
||||
week: "주",
|
||||
day: "일",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
"bg-background border shadow-sm",
|
||||
"hover:bg-accent/50 transition-colors",
|
||||
isDragging && "opacity-50 shadow-lg",
|
||||
// 필터 적용 시 강조 표시
|
||||
hasFilter
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{/* 필터 아이콘 (필터 적용 시) */}
|
||||
{hasFilter && (
|
||||
<Filter className="h-3 w-3 text-primary" />
|
||||
)}
|
||||
|
||||
{/* 필드 라벨 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 hover:text-primary">
|
||||
<span className={cn("font-medium", hasFilter && "text-primary")}>
|
||||
{field.caption}
|
||||
</span>
|
||||
{/* 그룹화 적용 표시 */}
|
||||
{hasGrouping && (
|
||||
<span className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary/80 text-[10px] px-1 rounded">
|
||||
{groupLabels[field.groupInterval!]}
|
||||
</span>
|
||||
)}
|
||||
{/* 필터 적용 개수 배지 */}
|
||||
{hasFilter && (
|
||||
<span className="bg-primary text-primary-foreground text-[10px] px-1 rounded">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
{field.area === "data" && field.summaryType && (
|
||||
<span className="text-muted-foreground">
|
||||
({getSummaryLabel(field.summaryType)})
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{field.area === "data" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "sum" })
|
||||
}
|
||||
>
|
||||
합계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "count" })
|
||||
}
|
||||
>
|
||||
개수
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "avg" })
|
||||
}
|
||||
>
|
||||
평균
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "min" })
|
||||
}
|
||||
>
|
||||
최소
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "max" })
|
||||
}
|
||||
>
|
||||
최대
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* 날짜 그룹화 옵션 (행/열 영역의 날짜 타입 필드만) */}
|
||||
{(field.area === "row" || field.area === "column") &&
|
||||
field.dataType === "date" && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
날짜 그룹화
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, groupInterval: undefined })}
|
||||
className="pl-6"
|
||||
>
|
||||
{!field.groupInterval && <Check className="h-3 w-3 mr-2" />}
|
||||
<span className={!field.groupInterval ? "font-medium" : ""}>그룹화 없음</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, groupInterval: "year" })}
|
||||
className="pl-6"
|
||||
>
|
||||
{field.groupInterval === "year" && <Check className="h-3 w-3 mr-2" />}
|
||||
<span className={field.groupInterval === "year" ? "font-medium" : ""}>연도별</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, groupInterval: "quarter" })}
|
||||
className="pl-6"
|
||||
>
|
||||
{field.groupInterval === "quarter" && <Check className="h-3 w-3 mr-2" />}
|
||||
<span className={field.groupInterval === "quarter" ? "font-medium" : ""}>분기별</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, groupInterval: "month" })}
|
||||
className="pl-6"
|
||||
>
|
||||
{field.groupInterval === "month" && <Check className="h-3 w-3 mr-2" />}
|
||||
<span className={field.groupInterval === "month" ? "font-medium" : ""}>월별</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, groupInterval: "week" })}
|
||||
className="pl-6"
|
||||
>
|
||||
{field.groupInterval === "week" && <Check className="h-3 w-3 mr-2" />}
|
||||
<span className={field.groupInterval === "week" ? "font-medium" : ""}>주별</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, groupInterval: "day" })}
|
||||
className="pl-6"
|
||||
>
|
||||
{field.groupInterval === "day" && <Check className="h-3 w-3 mr-2" />}
|
||||
<span className={field.groupInterval === "day" ? "font-medium" : ""}>일별</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({
|
||||
...field,
|
||||
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* 필터 초기화 (필터가 적용된 경우에만 표시) */}
|
||||
{hasFilter && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, filterValues: [] })}
|
||||
className="text-amber-600"
|
||||
>
|
||||
<Filter className="h-3 w-3 mr-2" />
|
||||
필터 초기화 ({filterCount}개 선택됨)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, visible: false })}
|
||||
>
|
||||
필드 숨기기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 드롭 영역 ====================
|
||||
|
||||
const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||
area,
|
||||
fields,
|
||||
title,
|
||||
icon,
|
||||
onFieldRemove,
|
||||
onFieldSettingsChange,
|
||||
isOver,
|
||||
}) => {
|
||||
const config = AREA_CONFIG[area];
|
||||
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
|
||||
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
|
||||
|
||||
// 🆕 드롭 가능 영역 설정
|
||||
const { setNodeRef, isOver: isOverDroppable } = useDroppable({
|
||||
id: area, // "filter", "column", "row", "data"
|
||||
});
|
||||
|
||||
const finalIsOver = isOver || isOverDroppable;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-[60px] rounded border-2 border-dashed p-2",
|
||||
"transition-all duration-200",
|
||||
config.color,
|
||||
finalIsOver && "border-primary bg-primary/10 scale-[1.02]",
|
||||
areaFields.length === 0 && "border-2" // 빈 영역일 때 테두리 강조
|
||||
)}
|
||||
data-area={area}
|
||||
>
|
||||
{/* 영역 헤더 */}
|
||||
<div className="flex items-center gap-1 mb-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{areaFields.length > 0 && (
|
||||
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{areaFields.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1 min-h-[28px] relative">
|
||||
{areaFields.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center w-full py-1 pointer-events-none"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground/70 italic font-medium">
|
||||
← 필드를 여기로 드래그하세요
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
areaFields.map((field) => (
|
||||
<SortableFieldChip
|
||||
key={`${area}-${field.field}`}
|
||||
field={field}
|
||||
onRemove={() => onFieldRemove(field)}
|
||||
onSettingsChange={onFieldSettingsChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function getSummaryLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
sum: "합계",
|
||||
count: "개수",
|
||||
avg: "평균",
|
||||
min: "최소",
|
||||
max: "최대",
|
||||
countDistinct: "고유",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onFieldRemove,
|
||||
onFieldSettingsChange,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
initialFields,
|
||||
}) => {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
|
||||
|
||||
// 필터만 초기화
|
||||
const handleResetFilters = () => {
|
||||
const newFields = fields.map((f) => ({
|
||||
...f,
|
||||
filterValues: [],
|
||||
filterType: "include" as const,
|
||||
}));
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 필드 배치 초기화 (initialFields가 있으면 사용, 없으면 모든 필드를 row로)
|
||||
const handleResetLayout = () => {
|
||||
if (initialFields && initialFields.length > 0) {
|
||||
// initialFields의 영역 배치를 복원하되 현재 필터 값은 유지
|
||||
const newFields = fields.map((f) => {
|
||||
const initial = initialFields.find((i) => i.field === f.field);
|
||||
if (initial) {
|
||||
return {
|
||||
...f,
|
||||
area: initial.area,
|
||||
areaIndex: initial.areaIndex,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
onFieldsChange(newFields);
|
||||
} else {
|
||||
// 기본값: 숫자는 data, 나머지는 row로
|
||||
const newFields = fields.map((f, idx) => ({
|
||||
...f,
|
||||
area: f.dataType === "number" ? "data" : "row" as PivotAreaType,
|
||||
areaIndex: idx,
|
||||
visible: true,
|
||||
}));
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 초기화 (필드 배치 + 필터)
|
||||
const handleResetAll = () => {
|
||||
if (initialFields && initialFields.length > 0) {
|
||||
// initialFields로 완전히 복원
|
||||
onFieldsChange([...initialFields]);
|
||||
} else {
|
||||
// 기본값으로 초기화
|
||||
const newFields = fields.map((f, idx) => ({
|
||||
...f,
|
||||
area: f.dataType === "number" ? "data" : "row" as PivotAreaType,
|
||||
areaIndex: idx,
|
||||
visible: true,
|
||||
filterValues: [],
|
||||
filterType: "include" as const,
|
||||
}));
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터가 적용된 필드 개수
|
||||
const filteredFieldCount = fields.filter(
|
||||
(f) => f.filterValues && f.filterValues.length > 0
|
||||
).length;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
if (!over) {
|
||||
setOverArea(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 드롭 영역 감지 (영역 자체의 ID를 우선 확인)
|
||||
const overId = over.id as string;
|
||||
|
||||
// 1. overId가 영역 자체인 경우 (filter, column, row, data)
|
||||
if (["filter", "column", "row", "data"].includes(overId)) {
|
||||
setOverArea(overId as PivotAreaType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. overId가 필드인 경우 (예: row-part_name)
|
||||
const targetArea = overId.split("-")[0] as PivotAreaType;
|
||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||
setOverArea(targetArea);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장
|
||||
setActiveId(null);
|
||||
setOverArea(null);
|
||||
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// 필드 정보 파싱
|
||||
const [sourceArea, sourceField] = activeId.split("-") as [
|
||||
PivotAreaType,
|
||||
string
|
||||
];
|
||||
|
||||
// targetArea 결정: handleDragOver에서 감지한 영역 우선 사용
|
||||
let targetArea: PivotAreaType;
|
||||
if (currentOverArea) {
|
||||
targetArea = currentOverArea;
|
||||
} else if (["filter", "column", "row", "data"].includes(overId)) {
|
||||
targetArea = overId as PivotAreaType;
|
||||
} else {
|
||||
targetArea = overId.split("-")[0] as PivotAreaType;
|
||||
}
|
||||
|
||||
// 같은 영역 내 정렬
|
||||
if (sourceArea === targetArea) {
|
||||
const areaFields = fields.filter((f) => f.area === sourceArea);
|
||||
const sourceIndex = areaFields.findIndex((f) => f.field === sourceField);
|
||||
const targetIndex = areaFields.findIndex(
|
||||
(f) => `${f.area}-${f.field}` === overId
|
||||
);
|
||||
|
||||
if (sourceIndex !== targetIndex && targetIndex >= 0) {
|
||||
// 순서 변경
|
||||
const newFields = [...fields];
|
||||
const fieldToMove = newFields.find(
|
||||
(f) => f.field === sourceField && f.area === sourceArea
|
||||
);
|
||||
if (fieldToMove) {
|
||||
fieldToMove.areaIndex = targetIndex;
|
||||
// 다른 필드들 인덱스 조정
|
||||
newFields
|
||||
.filter((f) => f.area === sourceArea && f.field !== sourceField)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
|
||||
.forEach((f, idx) => {
|
||||
f.areaIndex = idx >= targetIndex ? idx + 1 : idx;
|
||||
});
|
||||
}
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 영역으로 이동
|
||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||
const newFields = fields.map((f) => {
|
||||
if (f.field === sourceField && f.area === sourceArea) {
|
||||
return {
|
||||
...f,
|
||||
area: targetArea as PivotAreaType,
|
||||
areaIndex: fields.filter((ff) => ff.area === targetArea).length,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const handleFieldRemove = (field: PivotFieldConfig) => {
|
||||
if (onFieldRemove) {
|
||||
onFieldRemove(field);
|
||||
} else {
|
||||
// 기본 동작: visible을 false로 설정
|
||||
const newFields = fields.map((f) =>
|
||||
f.field === field.field && f.area === field.area
|
||||
? { ...f, visible: false }
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 설정 변경
|
||||
const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => {
|
||||
if (onFieldSettingsChange) {
|
||||
onFieldSettingsChange(updatedField);
|
||||
}
|
||||
const newFields = fields.map((f) =>
|
||||
f.field === updatedField.field && f.area === updatedField.area
|
||||
? updatedField
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 활성 필드 찾기 (드래그 중인 필드)
|
||||
const activeField = activeId
|
||||
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||
: null;
|
||||
|
||||
// 각 영역의 필드 수 계산
|
||||
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
|
||||
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
|
||||
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
|
||||
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{filterCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Filter className="h-3 w-3" />
|
||||
필터 {filterCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Columns className="h-3 w-3" />
|
||||
열 {columnCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Rows className="h-3 w-3" />
|
||||
행 {rowCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
데이터 {dataCount}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
필드 설정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="border-b border-border bg-muted/20 p-2">
|
||||
{/* 4개 영역 배치: 2x2 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 필터 영역 */}
|
||||
<DroppableArea
|
||||
area="filter"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.filter.title}
|
||||
icon={AREA_CONFIG.filter.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "filter"}
|
||||
/>
|
||||
|
||||
{/* 열 영역 */}
|
||||
<DroppableArea
|
||||
area="column"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.column.title}
|
||||
icon={AREA_CONFIG.column.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "column"}
|
||||
/>
|
||||
|
||||
{/* 행 영역 */}
|
||||
<DroppableArea
|
||||
area="row"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.row.title}
|
||||
icon={AREA_CONFIG.row.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "row"}
|
||||
/>
|
||||
|
||||
{/* 데이터 영역 */}
|
||||
<DroppableArea
|
||||
area="data"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.data.title}
|
||||
icon={AREA_CONFIG.data.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "data"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 영역 */}
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
{/* 초기화 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-6 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
초기화
|
||||
{filteredFieldCount > 0 && (
|
||||
<span className="ml-1 bg-amber-500 text-white text-[10px] px-1 rounded">
|
||||
{filteredFieldCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={handleResetFilters}>
|
||||
<FilterX className="h-3.5 w-3.5 mr-2 text-amber-500" />
|
||||
필터만 초기화
|
||||
{filteredFieldCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
({filteredFieldCount}개)
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleResetLayout}>
|
||||
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-primary" />
|
||||
필드 배치 초기화
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleResetAll} className="text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
전체 초기화
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 접기 버튼 */}
|
||||
{onToggleCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
필드 패널 접기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 드래그 오버레이 */}
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
"bg-background border border-primary shadow-lg"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">{activeField.caption}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldPanel;
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FilterPopup 컴포넌트
|
||||
* 피벗 필드의 값을 필터링하는 팝업
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig } from "../types";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Check,
|
||||
X,
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface FilterPopupProps {
|
||||
field: PivotFieldConfig;
|
||||
data: any[];
|
||||
onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FilterPopup: React.FC<FilterPopupProps> = ({
|
||||
field,
|
||||
data,
|
||||
onFilterChange,
|
||||
trigger,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedValues, setSelectedValues] = useState<Set<any>>(
|
||||
new Set(field.filterValues || [])
|
||||
);
|
||||
const [filterType, setFilterType] = useState<"include" | "exclude">(
|
||||
field.filterType || "include"
|
||||
);
|
||||
|
||||
// 고유 값 추출
|
||||
const uniqueValues = useMemo(() => {
|
||||
const values = new Set<any>();
|
||||
data.forEach((row) => {
|
||||
const value = row[field.field];
|
||||
if (value !== null && value !== undefined) {
|
||||
values.add(value);
|
||||
}
|
||||
});
|
||||
return Array.from(values).sort((a, b) => {
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
return String(a).localeCompare(String(b), "ko");
|
||||
});
|
||||
}, [data, field.field]);
|
||||
|
||||
// 필터링된 값 목록
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!searchQuery) return uniqueValues;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return uniqueValues.filter((val) =>
|
||||
String(val).toLowerCase().includes(query)
|
||||
);
|
||||
}, [uniqueValues, searchQuery]);
|
||||
|
||||
// 값 토글
|
||||
const handleValueToggle = (value: any) => {
|
||||
const newSelected = new Set(selectedValues);
|
||||
if (newSelected.has(value)) {
|
||||
newSelected.delete(value);
|
||||
} else {
|
||||
newSelected.add(value);
|
||||
}
|
||||
setSelectedValues(newSelected);
|
||||
};
|
||||
|
||||
// 모두 선택
|
||||
const handleSelectAll = () => {
|
||||
setSelectedValues(new Set(filteredValues));
|
||||
};
|
||||
|
||||
// 모두 해제
|
||||
const handleClearAll = () => {
|
||||
setSelectedValues(new Set());
|
||||
};
|
||||
|
||||
// 적용
|
||||
const handleApply = () => {
|
||||
onFilterChange(field, Array.from(selectedValues), filterType);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setSelectedValues(new Set());
|
||||
setFilterType("include");
|
||||
onFilterChange(field, [], "include");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 필터 활성 상태
|
||||
const isFilterActive = field.filterValues && field.filterValues.length > 0;
|
||||
|
||||
// 선택된 항목 수
|
||||
const selectedCount = selectedValues.size;
|
||||
const totalCount = uniqueValues.length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger || (
|
||||
<button
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-accent",
|
||||
isFilterActive && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{field.caption} 필터</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setFilterType("include")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded",
|
||||
filterType === "include"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
포함
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType("exclude")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded",
|
||||
filterType === "exclude"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
제외
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전체 선택/해제 */}
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{selectedCount} / {totalCount} 선택됨
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
전체 선택
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
전체 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 값 목록 */}
|
||||
<ScrollArea className="h-48">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredValues.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredValues.map((value) => (
|
||||
<label
|
||||
key={String(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer",
|
||||
"hover:bg-muted text-sm"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.has(value)}
|
||||
onCheckedChange={() => handleValueToggle(value)}
|
||||
/>
|
||||
<span className="truncate">{String(value)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
({data.filter((r) => r[field.field] === value).length})
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-between p-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPopup;
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotChart 컴포넌트
|
||||
* 피벗 데이터를 차트로 시각화
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types";
|
||||
import { pathToKey } from "../utils/pivotEngine";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface PivotChartProps {
|
||||
pivotResult: PivotResult;
|
||||
config: PivotChartConfig;
|
||||
dataFields: PivotFieldConfig[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ==================== 색상 ====================
|
||||
|
||||
const COLORS = [
|
||||
"#4472C4", // 파랑
|
||||
"#ED7D31", // 주황
|
||||
"#A5A5A5", // 회색
|
||||
"#FFC000", // 노랑
|
||||
"#5B9BD5", // 하늘
|
||||
"#70AD47", // 초록
|
||||
"#264478", // 진한 파랑
|
||||
"#9E480E", // 진한 주황
|
||||
"#636363", // 진한 회색
|
||||
"#997300", // 진한 노랑
|
||||
];
|
||||
|
||||
// ==================== 데이터 변환 ====================
|
||||
|
||||
function transformDataForChart(
|
||||
pivotResult: PivotResult,
|
||||
dataFields: PivotFieldConfig[]
|
||||
): any[] {
|
||||
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||||
|
||||
// 행 기준 차트 데이터 생성
|
||||
return flatRows.map((row) => {
|
||||
const dataPoint: any = {
|
||||
name: row.caption,
|
||||
path: row.path,
|
||||
};
|
||||
|
||||
// 각 열에 대한 데이터 추가
|
||||
flatColumns.forEach((col) => {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = dataMatrix.get(cellKey);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
const columnName = col.caption || "전체";
|
||||
dataPoint[columnName] = values[0].value;
|
||||
}
|
||||
});
|
||||
|
||||
// 총계 추가
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
if (rowTotal && rowTotal.length > 0) {
|
||||
dataPoint["총계"] = rowTotal[0].value;
|
||||
}
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
}
|
||||
|
||||
function transformDataForPie(
|
||||
pivotResult: PivotResult,
|
||||
dataFields: PivotFieldConfig[]
|
||||
): any[] {
|
||||
const { flatRows, grandTotals } = pivotResult;
|
||||
|
||||
return flatRows.map((row, idx) => {
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
return {
|
||||
name: row.caption,
|
||||
value: rowTotal?.[0]?.value || 0,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 차트 컴포넌트 ====================
|
||||
|
||||
const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
|
||||
<p className="text-sm font-medium mb-1">{label}</p>
|
||||
{payload.map((entry: any, idx: number) => (
|
||||
<p key={idx} className="text-xs" style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value?.toLocaleString()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 막대 차트
|
||||
const PivotBarChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
stacked?: boolean;
|
||||
}> = ({ data, columns, height, showLegend, stacked }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="square"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Bar
|
||||
key={col}
|
||||
dataKey={col}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
radius={stacked ? 0 : [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 선 차트
|
||||
const PivotLineChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, columns, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="line"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Line
|
||||
key={col}
|
||||
type="monotone"
|
||||
dataKey={col}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 영역 차트
|
||||
const PivotAreaChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, columns, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="square"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Area
|
||||
key={col}
|
||||
type="monotone"
|
||||
dataKey={col}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 파이 차트
|
||||
const PivotPieChart: React.FC<{
|
||||
data: any[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={height / 3}
|
||||
label={(({ name, percent }: { name: string; percent: number }) =>
|
||||
`${name} (${(percent * 100).toFixed(1)}%)`
|
||||
) as any}
|
||||
labelLine
|
||||
>
|
||||
{data.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="circle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotChart: React.FC<PivotChartProps> = ({
|
||||
pivotResult,
|
||||
config,
|
||||
dataFields,
|
||||
className,
|
||||
}) => {
|
||||
// 차트 데이터 변환
|
||||
const chartData = useMemo(() => {
|
||||
if (config.type === "pie") {
|
||||
return transformDataForPie(pivotResult, dataFields);
|
||||
}
|
||||
return transformDataForChart(pivotResult, dataFields);
|
||||
}, [pivotResult, dataFields, config.type]);
|
||||
|
||||
// 열 이름 목록 (파이 차트 제외)
|
||||
const columns = useMemo(() => {
|
||||
if (config.type === "pie" || chartData.length === 0) return [];
|
||||
|
||||
const firstItem = chartData[0];
|
||||
return Object.keys(firstItem).filter(
|
||||
(key) => key !== "name" && key !== "path"
|
||||
);
|
||||
}, [chartData, config.type]);
|
||||
|
||||
const height = config.height || 300;
|
||||
const showLegend = config.showLegend !== false;
|
||||
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-border bg-background p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 차트 렌더링 */}
|
||||
{config.type === "bar" && (
|
||||
<PivotBarChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "stackedBar" && (
|
||||
<PivotBarChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
stacked
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "line" && (
|
||||
<PivotLineChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "area" && (
|
||||
<PivotAreaChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "pie" && (
|
||||
<PivotPieChart
|
||||
data={chartData}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotChart;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 서브 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export { FieldPanel } from "./FieldPanel";
|
||||
export { FieldChooser } from "./FieldChooser";
|
||||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 커스텀 훅 내보내기
|
||||
*/
|
||||
|
||||
export {
|
||||
useVirtualScroll,
|
||||
useVirtualColumnScroll,
|
||||
useVirtual2DScroll,
|
||||
} from "./useVirtualScroll";
|
||||
|
||||
export type {
|
||||
VirtualScrollOptions,
|
||||
VirtualScrollResult,
|
||||
VirtualColumnScrollOptions,
|
||||
VirtualColumnScrollResult,
|
||||
Virtual2DScrollOptions,
|
||||
Virtual2DScrollResult,
|
||||
} from "./useVirtualScroll";
|
||||
|
||||
export { usePivotState } from "./usePivotState";
|
||||
|
||||
export type {
|
||||
PivotStateConfig,
|
||||
SavedPivotState,
|
||||
UsePivotStateResult,
|
||||
} from "./usePivotState";
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotState 훅
|
||||
* 피벗 그리드 상태 저장/복원 관리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { PivotFieldConfig, PivotGridState, SortDirection } from "../types";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface PivotStateConfig {
|
||||
enabled: boolean;
|
||||
storageKey?: string;
|
||||
storageType?: "localStorage" | "sessionStorage";
|
||||
}
|
||||
|
||||
export interface SavedPivotState {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
fields: PivotFieldConfig[];
|
||||
expandedRowPaths: string[][];
|
||||
expandedColumnPaths: string[][];
|
||||
filterConfig: Record<string, any[]>;
|
||||
sortConfig: {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UsePivotStateResult {
|
||||
// 상태
|
||||
fields: PivotFieldConfig[];
|
||||
pivotState: PivotGridState;
|
||||
|
||||
// 상태 변경
|
||||
setFields: (fields: PivotFieldConfig[]) => void;
|
||||
setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void;
|
||||
|
||||
// 저장/복원
|
||||
saveState: () => void;
|
||||
loadState: () => boolean;
|
||||
clearState: () => void;
|
||||
hasStoredState: () => boolean;
|
||||
|
||||
// 상태 정보
|
||||
lastSaved: Date | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
// ==================== 상수 ====================
|
||||
|
||||
const STATE_VERSION = "1.0.0";
|
||||
const DEFAULT_STORAGE_KEY = "pivot-grid-state";
|
||||
|
||||
// ==================== 훅 ====================
|
||||
|
||||
export function usePivotState(
|
||||
initialFields: PivotFieldConfig[],
|
||||
config: PivotStateConfig
|
||||
): UsePivotStateResult {
|
||||
const {
|
||||
enabled,
|
||||
storageKey = DEFAULT_STORAGE_KEY,
|
||||
storageType = "localStorage",
|
||||
} = config;
|
||||
|
||||
// 상태
|
||||
const [fields, setFieldsInternal] = useState<PivotFieldConfig[]>(initialFields);
|
||||
const [pivotState, setPivotStateInternal] = useState<PivotGridState>({
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
sortConfig: null,
|
||||
filterConfig: {},
|
||||
});
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialStateLoaded, setInitialStateLoaded] = useState(false);
|
||||
|
||||
// 스토리지 가져오기
|
||||
const getStorage = useCallback(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return storageType === "localStorage" ? localStorage : sessionStorage;
|
||||
}, [storageType]);
|
||||
|
||||
// 저장된 상태 확인
|
||||
const hasStoredState = useCallback((): boolean => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
return storage.getItem(storageKey) !== null;
|
||||
}, [getStorage, storageKey]);
|
||||
|
||||
// 상태 저장
|
||||
const saveState = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
const stateToSave: SavedPivotState = {
|
||||
version: STATE_VERSION,
|
||||
timestamp: Date.now(),
|
||||
fields,
|
||||
expandedRowPaths: pivotState.expandedRowPaths,
|
||||
expandedColumnPaths: pivotState.expandedColumnPaths,
|
||||
filterConfig: pivotState.filterConfig,
|
||||
sortConfig: pivotState.sortConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(stateToSave));
|
||||
setLastSaved(new Date());
|
||||
setIsDirty(false);
|
||||
console.log("✅ 피벗 상태 저장됨:", storageKey);
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 저장 실패:", error);
|
||||
}
|
||||
}, [enabled, getStorage, storageKey, fields, pivotState]);
|
||||
|
||||
// 상태 불러오기
|
||||
const loadState = useCallback((): boolean => {
|
||||
if (!enabled) return false;
|
||||
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
|
||||
try {
|
||||
const saved = storage.getItem(storageKey);
|
||||
if (!saved) return false;
|
||||
|
||||
const parsedState: SavedPivotState = JSON.parse(saved);
|
||||
|
||||
// 버전 체크
|
||||
if (parsedState.version !== STATE_VERSION) {
|
||||
console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 복원
|
||||
setFieldsInternal(parsedState.fields);
|
||||
setPivotStateInternal({
|
||||
expandedRowPaths: parsedState.expandedRowPaths,
|
||||
expandedColumnPaths: parsedState.expandedColumnPaths,
|
||||
sortConfig: parsedState.sortConfig,
|
||||
filterConfig: parsedState.filterConfig,
|
||||
});
|
||||
setLastSaved(new Date(parsedState.timestamp));
|
||||
setIsDirty(false);
|
||||
|
||||
console.log("✅ 피벗 상태 복원됨:", storageKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 복원 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}, [enabled, getStorage, storageKey]);
|
||||
|
||||
// 상태 초기화
|
||||
const clearState = useCallback(() => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
storage.removeItem(storageKey);
|
||||
setLastSaved(null);
|
||||
console.log("🗑️ 피벗 상태 삭제됨:", storageKey);
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 삭제 실패:", error);
|
||||
}
|
||||
}, [getStorage, storageKey]);
|
||||
|
||||
// 필드 변경 (dirty 플래그 설정)
|
||||
const setFields = useCallback((newFields: PivotFieldConfig[]) => {
|
||||
setFieldsInternal(newFields);
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
// 피벗 상태 변경 (dirty 플래그 설정)
|
||||
const setPivotState = useCallback(
|
||||
(newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => {
|
||||
setPivotStateInternal(newState);
|
||||
setIsDirty(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (!initialStateLoaded && enabled && hasStoredState()) {
|
||||
loadState();
|
||||
setInitialStateLoaded(true);
|
||||
}
|
||||
}, [enabled, hasStoredState, loadState, initialStateLoaded]);
|
||||
|
||||
// 초기 필드 동기화 (저장된 상태가 없을 때)
|
||||
useEffect(() => {
|
||||
if (initialStateLoaded) return;
|
||||
if (!hasStoredState() && initialFields.length > 0) {
|
||||
setFieldsInternal(initialFields);
|
||||
setInitialStateLoaded(true);
|
||||
}
|
||||
}, [initialFields, hasStoredState, initialStateLoaded]);
|
||||
|
||||
// 자동 저장 (변경 시)
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDirty) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
saveState();
|
||||
}, 1000); // 1초 디바운스
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [enabled, isDirty, saveState]);
|
||||
|
||||
return {
|
||||
fields,
|
||||
pivotState,
|
||||
setFields,
|
||||
setPivotState,
|
||||
saveState,
|
||||
loadState,
|
||||
clearState,
|
||||
hasStoredState,
|
||||
lastSaved,
|
||||
isDirty,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePivotState;
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Virtual Scroll 훅
|
||||
* 대용량 피벗 데이터의 가상 스크롤 처리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemCount: number; // 전체 아이템 수
|
||||
itemHeight: number; // 각 아이템 높이 (px)
|
||||
containerHeight: number; // 컨테이너 높이 (px)
|
||||
overscan?: number; // 버퍼 아이템 수 (기본: 5)
|
||||
}
|
||||
|
||||
export interface VirtualScrollResult {
|
||||
// 현재 보여야 할 아이템 범위
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
|
||||
// 가상 스크롤 관련 값
|
||||
totalHeight: number; // 전체 높이
|
||||
offsetTop: number; // 상단 오프셋
|
||||
|
||||
// 보여지는 아이템 목록
|
||||
visibleItems: number[];
|
||||
|
||||
// 이벤트 핸들러
|
||||
onScroll: (scrollTop: number) => void;
|
||||
|
||||
// 컨테이너 ref
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// ==================== 훅 ====================
|
||||
|
||||
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
|
||||
const {
|
||||
itemCount,
|
||||
itemHeight,
|
||||
containerHeight,
|
||||
overscan = 5,
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
// 보이는 아이템 수
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||
|
||||
// 시작/끝 인덱스 계산 (음수 방지)
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
// itemCount가 0이면 빈 배열
|
||||
if (itemCount === 0) {
|
||||
return { startIndex: 0, endIndex: -1 };
|
||||
}
|
||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const end = Math.min(
|
||||
itemCount - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
||||
);
|
||||
return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록
|
||||
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
|
||||
|
||||
// 전체 높이
|
||||
const totalHeight = itemCount * itemHeight;
|
||||
|
||||
// 상단 오프셋
|
||||
const offsetTop = startIndex * itemHeight;
|
||||
|
||||
// 보이는 아이템 인덱스 배열
|
||||
const visibleItems = useMemo(() => {
|
||||
const items: number[] = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
return items;
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
// 스크롤 핸들러
|
||||
const onScroll = useCallback((newScrollTop: number) => {
|
||||
setScrollTop(newScrollTop);
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(container.scrollTop);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
visibleItems,
|
||||
onScroll,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 열 가상 스크롤 ====================
|
||||
|
||||
export interface VirtualColumnScrollOptions {
|
||||
columnCount: number; // 전체 열 수
|
||||
columnWidth: number; // 각 열 너비 (px)
|
||||
containerWidth: number; // 컨테이너 너비 (px)
|
||||
overscan?: number;
|
||||
}
|
||||
|
||||
export interface VirtualColumnScrollResult {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalWidth: number;
|
||||
offsetLeft: number;
|
||||
visibleColumns: number[];
|
||||
onScroll: (scrollLeft: number) => void;
|
||||
}
|
||||
|
||||
export function useVirtualColumnScroll(
|
||||
options: VirtualColumnScrollOptions
|
||||
): VirtualColumnScrollResult {
|
||||
const {
|
||||
columnCount,
|
||||
columnWidth,
|
||||
containerWidth,
|
||||
overscan = 3,
|
||||
} = options;
|
||||
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan);
|
||||
const end = Math.min(
|
||||
columnCount - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan
|
||||
);
|
||||
return { startIndex: start, endIndex: end };
|
||||
}, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]);
|
||||
|
||||
const totalWidth = columnCount * columnWidth;
|
||||
const offsetLeft = startIndex * columnWidth;
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
const cols: number[] = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
cols.push(i);
|
||||
}
|
||||
return cols;
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
const onScroll = useCallback((newScrollLeft: number) => {
|
||||
setScrollLeft(newScrollLeft);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalWidth,
|
||||
offsetLeft,
|
||||
visibleColumns,
|
||||
onScroll,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 2D 가상 스크롤 (행 + 열) ====================
|
||||
|
||||
export interface Virtual2DScrollOptions {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
rowHeight: number;
|
||||
columnWidth: number;
|
||||
containerHeight: number;
|
||||
containerWidth: number;
|
||||
rowOverscan?: number;
|
||||
columnOverscan?: number;
|
||||
}
|
||||
|
||||
export interface Virtual2DScrollResult {
|
||||
// 행 범위
|
||||
rowStartIndex: number;
|
||||
rowEndIndex: number;
|
||||
totalHeight: number;
|
||||
offsetTop: number;
|
||||
visibleRows: number[];
|
||||
|
||||
// 열 범위
|
||||
columnStartIndex: number;
|
||||
columnEndIndex: number;
|
||||
totalWidth: number;
|
||||
offsetLeft: number;
|
||||
visibleColumns: number[];
|
||||
|
||||
// 스크롤 핸들러
|
||||
onScroll: (scrollTop: number, scrollLeft: number) => void;
|
||||
|
||||
// 컨테이너 ref
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useVirtual2DScroll(
|
||||
options: Virtual2DScrollOptions
|
||||
): Virtual2DScrollResult {
|
||||
const {
|
||||
rowCount,
|
||||
columnCount,
|
||||
rowHeight,
|
||||
columnWidth,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
rowOverscan = 5,
|
||||
columnOverscan = 3,
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// 행 계산
|
||||
const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan);
|
||||
const end = Math.min(
|
||||
rowCount - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan
|
||||
);
|
||||
|
||||
const rows: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
rows.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
rowStartIndex: start,
|
||||
rowEndIndex: end,
|
||||
visibleRows: rows,
|
||||
};
|
||||
}, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]);
|
||||
|
||||
// 열 계산
|
||||
const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan);
|
||||
const end = Math.min(
|
||||
columnCount - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan
|
||||
);
|
||||
|
||||
const cols: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
cols.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
columnStartIndex: start,
|
||||
columnEndIndex: end,
|
||||
visibleColumns: cols,
|
||||
};
|
||||
}, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]);
|
||||
|
||||
const totalHeight = rowCount * rowHeight;
|
||||
const totalWidth = columnCount * columnWidth;
|
||||
const offsetTop = rowStartIndex * rowHeight;
|
||||
const offsetLeft = columnStartIndex * columnWidth;
|
||||
|
||||
const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => {
|
||||
setScrollTop(newScrollTop);
|
||||
setScrollLeft(newScrollLeft);
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(container.scrollTop);
|
||||
setScrollLeft(container.scrollLeft);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rowStartIndex,
|
||||
rowEndIndex,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
visibleRows,
|
||||
columnStartIndex,
|
||||
columnEndIndex,
|
||||
totalWidth,
|
||||
offsetLeft,
|
||||
visibleColumns,
|
||||
onScroll,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
|
||||
export default useVirtualScroll;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 컴포넌트 모듈
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
// 기본 타입
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
SummaryDisplayMode,
|
||||
SortDirection,
|
||||
DateGroupInterval,
|
||||
FieldDataType,
|
||||
DataSourceType,
|
||||
// 필드 설정
|
||||
PivotFieldFormat,
|
||||
PivotFieldConfig,
|
||||
// 데이터 소스
|
||||
PivotFilterCondition,
|
||||
PivotJoinConfig,
|
||||
PivotDataSourceConfig,
|
||||
// 표시 설정
|
||||
PivotTotalsConfig,
|
||||
FieldChooserConfig,
|
||||
PivotChartConfig,
|
||||
PivotStyleConfig,
|
||||
PivotExportConfig,
|
||||
// Props
|
||||
PivotGridProps,
|
||||
// 결과 데이터
|
||||
PivotCellData,
|
||||
PivotHeaderNode,
|
||||
PivotCellValue,
|
||||
PivotResult,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
// 상태
|
||||
PivotGridState,
|
||||
// Config
|
||||
PivotGridComponentConfig,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { PivotGridComponent } from "./PivotGridComponent";
|
||||
export { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||
|
||||
// 유틸리티
|
||||
export {
|
||||
aggregate,
|
||||
sum,
|
||||
count,
|
||||
avg,
|
||||
min,
|
||||
max,
|
||||
countDistinct,
|
||||
formatNumber,
|
||||
formatDate,
|
||||
getAggregationLabel,
|
||||
} from "./utils/aggregation";
|
||||
|
||||
export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine";
|
||||
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 컴포넌트 타입 정의
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
// ==================== 기본 타입 ====================
|
||||
|
||||
// 필드 영역 타입
|
||||
export type PivotAreaType = "row" | "column" | "data" | "filter";
|
||||
|
||||
// 집계 함수 타입
|
||||
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
||||
|
||||
// 요약 표시 모드
|
||||
export type SummaryDisplayMode =
|
||||
| "absoluteValue" // 절대값 (기본)
|
||||
| "percentOfColumnTotal" // 열 총계 대비 %
|
||||
| "percentOfRowTotal" // 행 총계 대비 %
|
||||
| "percentOfGrandTotal" // 전체 총계 대비 %
|
||||
| "percentOfColumnGrandTotal" // 열 대총계 대비 %
|
||||
| "percentOfRowGrandTotal" // 행 대총계 대비 %
|
||||
| "runningTotalByRow" // 행 방향 누계
|
||||
| "runningTotalByColumn" // 열 방향 누계
|
||||
| "differenceFromPrevious" // 이전 대비 차이
|
||||
| "percentDifferenceFromPrevious"; // 이전 대비 % 차이
|
||||
|
||||
// 정렬 방향
|
||||
export type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
// 날짜 그룹 간격
|
||||
export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day";
|
||||
|
||||
// 필드 데이터 타입
|
||||
export type FieldDataType = "string" | "number" | "date" | "boolean";
|
||||
|
||||
// 데이터 소스 타입
|
||||
export type DataSourceType = "table" | "api" | "static";
|
||||
|
||||
// ==================== 필드 설정 ====================
|
||||
|
||||
// 필드 포맷 설정
|
||||
export interface PivotFieldFormat {
|
||||
type: "number" | "currency" | "percent" | "date" | "text";
|
||||
precision?: number; // 소수점 자릿수
|
||||
thousandSeparator?: boolean; // 천단위 구분자
|
||||
prefix?: string; // 접두사 (예: "$", "₩")
|
||||
suffix?: string; // 접미사 (예: "%", "원")
|
||||
dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD")
|
||||
}
|
||||
|
||||
// 필드 설정
|
||||
export interface PivotFieldConfig {
|
||||
// 기본 정보
|
||||
field: string; // 데이터 필드명
|
||||
caption: string; // 표시 라벨
|
||||
area: PivotAreaType; // 배치 영역
|
||||
areaIndex?: number; // 영역 내 순서
|
||||
|
||||
// 데이터 타입
|
||||
dataType?: FieldDataType; // 데이터 타입
|
||||
|
||||
// 집계 설정 (data 영역용)
|
||||
summaryType?: AggregationType; // 집계 함수
|
||||
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
|
||||
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
|
||||
|
||||
// 정렬 설정
|
||||
sortBy?: "value" | "caption"; // 정렬 기준
|
||||
sortOrder?: SortDirection; // 정렬 방향
|
||||
sortBySummary?: string; // 요약값 기준 정렬 (data 필드명)
|
||||
|
||||
// 날짜 그룹화 설정
|
||||
groupInterval?: DateGroupInterval; // 날짜 그룹 간격
|
||||
groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성)
|
||||
|
||||
// 표시 설정
|
||||
visible?: boolean; // 표시 여부
|
||||
width?: number; // 컬럼 너비
|
||||
expanded?: boolean; // 기본 확장 상태
|
||||
|
||||
// 포맷 설정
|
||||
format?: PivotFieldFormat; // 값 포맷
|
||||
|
||||
// 필터 설정
|
||||
filterValues?: any[]; // 선택된 필터 값
|
||||
filterType?: "include" | "exclude"; // 필터 타입
|
||||
allowFiltering?: boolean; // 필터링 허용
|
||||
allowSorting?: boolean; // 정렬 허용
|
||||
|
||||
// 계층 관련
|
||||
displayFolder?: string; // 필드 선택기에서 폴더 구조
|
||||
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
||||
|
||||
// 계산 필드
|
||||
isCalculated?: boolean; // 계산 필드 여부
|
||||
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
|
||||
}
|
||||
|
||||
// ==================== 데이터 소스 설정 ====================
|
||||
|
||||
// 필터 조건
|
||||
export interface PivotFilterCondition {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
value?: any;
|
||||
valueFromField?: string; // formData에서 값 가져오기
|
||||
}
|
||||
|
||||
// 조인 설정
|
||||
export interface PivotJoinConfig {
|
||||
joinType: "INNER" | "LEFT" | "RIGHT";
|
||||
targetTable: string;
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
columns: string[]; // 가져올 컬럼들
|
||||
}
|
||||
|
||||
// 데이터 소스 설정
|
||||
export interface PivotDataSourceConfig {
|
||||
type: DataSourceType;
|
||||
|
||||
// 테이블 기반
|
||||
tableName?: string; // 테이블명
|
||||
|
||||
// API 기반
|
||||
apiEndpoint?: string; // API 엔드포인트
|
||||
apiMethod?: "GET" | "POST"; // HTTP 메서드
|
||||
|
||||
// 정적 데이터
|
||||
staticData?: any[]; // 정적 데이터
|
||||
|
||||
// 필터 조건
|
||||
filterConditions?: PivotFilterCondition[];
|
||||
|
||||
// 조인 설정
|
||||
joinConfigs?: PivotJoinConfig[];
|
||||
}
|
||||
|
||||
// ==================== 표시 설정 ====================
|
||||
|
||||
// 총합계 표시 설정
|
||||
export interface PivotTotalsConfig {
|
||||
// 행 총합계
|
||||
showRowGrandTotals?: boolean; // 행 총합계 표시
|
||||
showRowTotals?: boolean; // 행 소계 표시
|
||||
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
|
||||
|
||||
// 열 총합계
|
||||
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
||||
showColumnTotals?: boolean; // 열 소계 표시
|
||||
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
|
||||
}
|
||||
|
||||
// 필드 선택기 설정
|
||||
export interface FieldChooserConfig {
|
||||
enabled: boolean; // 활성화 여부
|
||||
allowSearch?: boolean; // 검색 허용
|
||||
layout?: "default" | "simplified"; // 레이아웃
|
||||
height?: number; // 높이
|
||||
applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점
|
||||
}
|
||||
|
||||
// 차트 연동 설정
|
||||
export interface PivotChartConfig {
|
||||
enabled: boolean; // 차트 표시 여부
|
||||
type: "bar" | "line" | "area" | "pie" | "stackedBar";
|
||||
position: "top" | "bottom" | "left" | "right";
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
// 조건부 서식 규칙
|
||||
export interface ConditionalFormatRule {
|
||||
id: string;
|
||||
type: "colorScale" | "dataBar" | "iconSet" | "cellValue";
|
||||
field?: string; // 적용할 데이터 필드 (없으면 전체)
|
||||
|
||||
// colorScale: 값 범위에 따른 색상 그라데이션
|
||||
colorScale?: {
|
||||
minColor: string; // 최소값 색상 (예: "#ff0000")
|
||||
midColor?: string; // 중간값 색상 (선택)
|
||||
maxColor: string; // 최대값 색상 (예: "#00ff00")
|
||||
};
|
||||
|
||||
// dataBar: 값에 따른 막대 표시
|
||||
dataBar?: {
|
||||
color: string; // 막대 색상
|
||||
showValue?: boolean; // 값 표시 여부
|
||||
minValue?: number; // 최소값 (없으면 자동)
|
||||
maxValue?: number; // 최대값 (없으면 자동)
|
||||
};
|
||||
|
||||
// iconSet: 값에 따른 아이콘 표시
|
||||
iconSet?: {
|
||||
type: "arrows" | "traffic" | "rating" | "flags";
|
||||
thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100)
|
||||
reverse?: boolean; // 아이콘 순서 반전
|
||||
};
|
||||
|
||||
// cellValue: 조건에 따른 스타일
|
||||
cellValue?: {
|
||||
operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between";
|
||||
value1: number;
|
||||
value2?: number; // between 연산자용
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
bold?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 스타일 설정
|
||||
export interface PivotStyleConfig {
|
||||
theme: "default" | "compact" | "modern";
|
||||
headerStyle: "default" | "dark" | "light";
|
||||
cellPadding: "compact" | "normal" | "comfortable";
|
||||
borderStyle: "none" | "light" | "heavy";
|
||||
alternateRowColors?: boolean;
|
||||
highlightTotals?: boolean; // 총합계 강조
|
||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||
mergeCells?: boolean; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
||||
export interface PivotExportConfig {
|
||||
excel?: boolean;
|
||||
pdf?: boolean;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// ==================== 메인 Props ====================
|
||||
|
||||
export interface PivotGridProps {
|
||||
// 기본 설정
|
||||
id?: string;
|
||||
title?: string;
|
||||
|
||||
// 데이터 소스
|
||||
dataSource?: PivotDataSourceConfig;
|
||||
|
||||
// 필드 설정
|
||||
fields?: PivotFieldConfig[];
|
||||
|
||||
// 표시 설정
|
||||
totals?: PivotTotalsConfig;
|
||||
style?: PivotStyleConfig;
|
||||
|
||||
// 필드 선택기
|
||||
fieldChooser?: FieldChooserConfig;
|
||||
|
||||
// 차트 연동
|
||||
chart?: PivotChartConfig;
|
||||
|
||||
// 기능 설정
|
||||
allowSortingBySummary?: boolean; // 요약값 기준 정렬
|
||||
allowFiltering?: boolean; // 필터링 허용
|
||||
allowExpandAll?: boolean; // 전체 확장/축소 허용
|
||||
wordWrapEnabled?: boolean; // 텍스트 줄바꿈
|
||||
|
||||
// 크기 설정
|
||||
height?: string | number;
|
||||
maxHeight?: string;
|
||||
|
||||
// 상태 저장
|
||||
stateStoring?: {
|
||||
enabled: boolean;
|
||||
storageKey?: string; // localStorage 키
|
||||
};
|
||||
|
||||
// 내보내기
|
||||
exportConfig?: PivotExportConfig;
|
||||
|
||||
// 데이터 (외부 주입용)
|
||||
data?: any[];
|
||||
|
||||
// 이벤트
|
||||
onCellClick?: (cellData: PivotCellData) => void;
|
||||
onCellDoubleClick?: (cellData: PivotCellData) => void;
|
||||
onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void;
|
||||
onExpandChange?: (expandedPaths: string[][]) => void;
|
||||
onDataChange?: (data: any[]) => void;
|
||||
}
|
||||
|
||||
// ==================== 결과 데이터 구조 ====================
|
||||
|
||||
// 셀 데이터
|
||||
export interface PivotCellData {
|
||||
value: any; // 셀 값
|
||||
rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"])
|
||||
columnPath: string[]; // 열 경로 (예: ["2024", "Q1"])
|
||||
field?: string; // 데이터 필드명
|
||||
aggregationType?: AggregationType;
|
||||
isTotal?: boolean; // 총합계 여부
|
||||
isGrandTotal?: boolean; // 대총합 여부
|
||||
}
|
||||
|
||||
// 헤더 노드 (트리 구조)
|
||||
export interface PivotHeaderNode {
|
||||
value: any; // 원본 값
|
||||
caption: string; // 표시 텍스트
|
||||
level: number; // 깊이
|
||||
children?: PivotHeaderNode[]; // 자식 노드
|
||||
isExpanded: boolean; // 확장 상태
|
||||
hasChildren: boolean; // 자식 존재 가능 여부 (다음 레벨 필드 있음)
|
||||
path: string[]; // 경로 (드릴다운용)
|
||||
subtotal?: PivotCellValue[]; // 소계
|
||||
span?: number; // colspan/rowspan
|
||||
}
|
||||
|
||||
// 셀 값
|
||||
export interface PivotCellValue {
|
||||
field: string; // 데이터 필드
|
||||
value: number | null; // 집계 값
|
||||
formattedValue: string; // 포맷된 값
|
||||
}
|
||||
|
||||
// 피벗 결과 데이터 구조
|
||||
export interface PivotResult {
|
||||
// 행 헤더 트리
|
||||
rowHeaders: PivotHeaderNode[];
|
||||
|
||||
// 열 헤더 트리
|
||||
columnHeaders: PivotHeaderNode[];
|
||||
|
||||
// 데이터 매트릭스 (rowPath + columnPath → values)
|
||||
dataMatrix: Map<string, PivotCellValue[]>;
|
||||
|
||||
// 플랫 행 목록 (렌더링용)
|
||||
flatRows: PivotFlatRow[];
|
||||
|
||||
// 플랫 열 목록 (렌더링용) - 리프 노드만
|
||||
flatColumns: PivotFlatColumn[];
|
||||
|
||||
// 열 헤더 레벨별 (다중 행 헤더용)
|
||||
columnHeaderLevels: PivotColumnHeaderCell[][];
|
||||
|
||||
// 총합계
|
||||
grandTotals: {
|
||||
row: Map<string, PivotCellValue[]>; // 행별 총합
|
||||
column: Map<string, PivotCellValue[]>; // 열별 총합
|
||||
grand: PivotCellValue[]; // 대총합
|
||||
};
|
||||
}
|
||||
|
||||
// 플랫 행 (렌더링용)
|
||||
export interface PivotFlatRow {
|
||||
path: string[];
|
||||
level: number;
|
||||
caption: string;
|
||||
isExpanded: boolean;
|
||||
hasChildren: boolean;
|
||||
isTotal?: boolean;
|
||||
}
|
||||
|
||||
// 플랫 열 (렌더링용)
|
||||
export interface PivotFlatColumn {
|
||||
path: string[];
|
||||
level: number;
|
||||
caption: string;
|
||||
span: number;
|
||||
isTotal?: boolean;
|
||||
}
|
||||
|
||||
// 열 헤더 셀 (다중 행 헤더용)
|
||||
export interface PivotColumnHeaderCell {
|
||||
caption: string; // 표시 텍스트
|
||||
colSpan: number; // 병합할 열 수
|
||||
path: string[]; // 전체 경로
|
||||
level: number; // 레벨 (0부터 시작)
|
||||
}
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
export interface PivotGridState {
|
||||
expandedRowPaths: string[][]; // 확장된 행 경로들
|
||||
expandedColumnPaths: string[][]; // 확장된 열 경로들
|
||||
sortConfig: {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
} | null;
|
||||
filterConfig: Record<string, any[]>; // 필드별 필터값
|
||||
}
|
||||
|
||||
// ==================== 컴포넌트 Config (화면관리용) ====================
|
||||
|
||||
export interface PivotGridComponentConfig {
|
||||
// 데이터 소스
|
||||
dataSource?: PivotDataSourceConfig;
|
||||
|
||||
// 필드 설정
|
||||
fields?: PivotFieldConfig[];
|
||||
|
||||
// 표시 설정
|
||||
totals?: PivotTotalsConfig;
|
||||
style?: PivotStyleConfig;
|
||||
|
||||
// 필드 선택기
|
||||
fieldChooser?: FieldChooserConfig;
|
||||
|
||||
// 차트 연동
|
||||
chart?: PivotChartConfig;
|
||||
|
||||
// 기능 설정
|
||||
allowSortingBySummary?: boolean;
|
||||
allowFiltering?: boolean;
|
||||
allowExpandAll?: boolean;
|
||||
wordWrapEnabled?: boolean;
|
||||
|
||||
// 크기 설정
|
||||
height?: string | number;
|
||||
maxHeight?: string;
|
||||
|
||||
// 내보내기
|
||||
exportConfig?: PivotExportConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 집계 함수 유틸리티
|
||||
* 다양한 집계 연산을 수행합니다.
|
||||
*/
|
||||
|
||||
import { getFormatRules } from "@/lib/formatting";
|
||||
|
||||
import { AggregationType, PivotFieldFormat } from "../types";
|
||||
|
||||
// ==================== 집계 함수 ====================
|
||||
|
||||
/**
|
||||
* 합계 계산
|
||||
*/
|
||||
export function sum(values: number[]): number {
|
||||
return values.reduce((acc, val) => acc + (val || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개수 계산
|
||||
*/
|
||||
export function count(values: any[]): number {
|
||||
return values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균 계산
|
||||
*/
|
||||
export function avg(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return sum(values) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최소값 계산
|
||||
*/
|
||||
export function min(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return Math.min(...values.filter((v) => v !== null && v !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대값 계산
|
||||
*/
|
||||
export function max(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return Math.max(...values.filter((v) => v !== null && v !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유값 개수 계산
|
||||
*/
|
||||
export function countDistinct(values: any[]): number {
|
||||
return new Set(values.filter((v) => v !== null && v !== undefined)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 타입에 따른 집계 수행
|
||||
*/
|
||||
export function aggregate(
|
||||
values: any[],
|
||||
type: AggregationType = "sum"
|
||||
): number {
|
||||
const numericValues = values
|
||||
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
|
||||
.filter((v) => !isNaN(v));
|
||||
|
||||
switch (type) {
|
||||
case "sum":
|
||||
return sum(numericValues);
|
||||
case "count":
|
||||
return count(values);
|
||||
case "avg":
|
||||
return avg(numericValues);
|
||||
case "min":
|
||||
return min(numericValues);
|
||||
case "max":
|
||||
return max(numericValues);
|
||||
case "countDistinct":
|
||||
return countDistinct(values);
|
||||
default:
|
||||
return sum(numericValues);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 포맷 함수 ====================
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number | null | undefined,
|
||||
format?: PivotFieldFormat
|
||||
): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
const {
|
||||
type = "number",
|
||||
precision = 0,
|
||||
thousandSeparator = true,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
} = format || {};
|
||||
|
||||
let formatted: string;
|
||||
|
||||
const locale = getFormatRules().number.locale;
|
||||
|
||||
switch (type) {
|
||||
case "currency":
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "percent":
|
||||
formatted = (value * 100).toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "number":
|
||||
default:
|
||||
if (thousandSeparator) {
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
} else {
|
||||
formatted = value.toFixed(precision);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return `${prefix}${formatted}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
export function formatDate(
|
||||
value: Date | string | null | undefined,
|
||||
format: string = getFormatRules().date.display
|
||||
): string {
|
||||
if (!value) return "-";
|
||||
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const quarter = Math.ceil((date.getMonth() + 1) / 3);
|
||||
|
||||
return format
|
||||
.replace("YYYY", String(year))
|
||||
.replace("MM", month)
|
||||
.replace("DD", day)
|
||||
.replace("Q", `Q${quarter}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 타입 라벨 반환
|
||||
*/
|
||||
export function getAggregationLabel(type: AggregationType): string {
|
||||
const labels: Record<AggregationType, string> = {
|
||||
sum: "합계",
|
||||
count: "개수",
|
||||
avg: "평균",
|
||||
min: "최소",
|
||||
max: "최대",
|
||||
countDistinct: "고유값",
|
||||
};
|
||||
return labels[type] || "합계";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
/**
|
||||
* 조건부 서식 유틸리티
|
||||
* 셀 값에 따른 스타일 계산
|
||||
*/
|
||||
|
||||
import { ConditionalFormatRule } from "../types";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface CellFormatStyle {
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
fontWeight?: string;
|
||||
dataBarWidth?: number; // 0-100%
|
||||
dataBarColor?: string;
|
||||
icon?: string; // 이모지 또는 아이콘 이름
|
||||
}
|
||||
|
||||
// ==================== 색상 유틸리티 ====================
|
||||
|
||||
/**
|
||||
* HEX 색상을 RGB로 변환
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RGB를 HEX로 변환
|
||||
*/
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = Math.round(x).toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 색상 사이의 보간
|
||||
*/
|
||||
function interpolateColor(
|
||||
color1: string,
|
||||
color2: string,
|
||||
factor: number
|
||||
): string {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return color1;
|
||||
|
||||
const r = rgb1.r + (rgb2.r - rgb1.r) * factor;
|
||||
const g = rgb1.g + (rgb2.g - rgb1.g) * factor;
|
||||
const b = rgb1.b + (rgb2.b - rgb1.b) * factor;
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// ==================== 조건부 서식 계산 ====================
|
||||
|
||||
/**
|
||||
* Color Scale 스타일 계산
|
||||
*/
|
||||
function applyColorScale(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.colorScale) return {};
|
||||
|
||||
const { minColor, midColor, maxColor } = rule.colorScale;
|
||||
const range = maxValue - minValue;
|
||||
|
||||
if (range === 0) {
|
||||
return { backgroundColor: minColor };
|
||||
}
|
||||
|
||||
const normalizedValue = (value - minValue) / range;
|
||||
|
||||
let backgroundColor: string;
|
||||
|
||||
if (midColor) {
|
||||
// 3색 그라데이션
|
||||
if (normalizedValue <= 0.5) {
|
||||
backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2);
|
||||
} else {
|
||||
backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2);
|
||||
}
|
||||
} else {
|
||||
// 2색 그라데이션
|
||||
backgroundColor = interpolateColor(minColor, maxColor, normalizedValue);
|
||||
}
|
||||
|
||||
// 배경색에 따른 텍스트 색상 결정
|
||||
const rgb = hexToRgb(backgroundColor);
|
||||
const textColor =
|
||||
rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
|
||||
return { backgroundColor, textColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Bar 스타일 계산
|
||||
*/
|
||||
function applyDataBar(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.dataBar) return {};
|
||||
|
||||
const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar;
|
||||
|
||||
const min = ruleMin ?? minValue;
|
||||
const max = ruleMax ?? maxValue;
|
||||
const range = max - min;
|
||||
|
||||
if (range === 0) {
|
||||
return { dataBarWidth: 100, dataBarColor: color };
|
||||
}
|
||||
|
||||
const width = Math.max(0, Math.min(100, ((value - min) / range) * 100));
|
||||
|
||||
return {
|
||||
dataBarWidth: width,
|
||||
dataBarColor: color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon Set 스타일 계산
|
||||
*/
|
||||
function applyIconSet(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.iconSet) return {};
|
||||
|
||||
const { type, thresholds, reverse } = rule.iconSet;
|
||||
const range = maxValue - minValue;
|
||||
const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100;
|
||||
|
||||
// 아이콘 정의
|
||||
const iconSets: Record<string, string[]> = {
|
||||
arrows: ["↓", "→", "↑"],
|
||||
traffic: ["🔴", "🟡", "🟢"],
|
||||
rating: ["⭐", "⭐⭐", "⭐⭐⭐"],
|
||||
flags: ["🚩", "🏳️", "🏁"],
|
||||
};
|
||||
|
||||
const icons = iconSets[type] || iconSets.arrows;
|
||||
const sortedIcons = reverse ? [...icons].reverse() : icons;
|
||||
|
||||
// 임계값에 따른 아이콘 선택
|
||||
let iconIndex = 0;
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
if (percentage >= thresholds[i]) {
|
||||
iconIndex = i + 1;
|
||||
}
|
||||
}
|
||||
iconIndex = Math.min(iconIndex, sortedIcons.length - 1);
|
||||
|
||||
return {
|
||||
icon: sortedIcons[iconIndex],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell Value 조건 스타일 계산
|
||||
*/
|
||||
function applyCellValue(
|
||||
value: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.cellValue) return {};
|
||||
|
||||
const { operator, value1, value2, backgroundColor, textColor, bold } =
|
||||
rule.cellValue;
|
||||
|
||||
let matches = false;
|
||||
|
||||
switch (operator) {
|
||||
case ">":
|
||||
matches = value > value1;
|
||||
break;
|
||||
case ">=":
|
||||
matches = value >= value1;
|
||||
break;
|
||||
case "<":
|
||||
matches = value < value1;
|
||||
break;
|
||||
case "<=":
|
||||
matches = value <= value1;
|
||||
break;
|
||||
case "=":
|
||||
matches = value === value1;
|
||||
break;
|
||||
case "!=":
|
||||
matches = value !== value1;
|
||||
break;
|
||||
case "between":
|
||||
matches = value2 !== undefined && value >= value1 && value <= value2;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!matches) return {};
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
textColor,
|
||||
fontWeight: bold ? "bold" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 조건부 서식 적용
|
||||
*/
|
||||
export function getConditionalStyle(
|
||||
value: number | null | undefined,
|
||||
field: string,
|
||||
rules: ConditionalFormatRule[],
|
||||
allValues: number[] // 해당 필드의 모든 값 (min/max 계산용)
|
||||
): CellFormatStyle {
|
||||
if (value === null || value === undefined || isNaN(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!rules || rules.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// min/max 계산
|
||||
const numericValues = allValues.filter((v) => !isNaN(v));
|
||||
const minValue = Math.min(...numericValues);
|
||||
const maxValue = Math.max(...numericValues);
|
||||
|
||||
let resultStyle: CellFormatStyle = {};
|
||||
|
||||
// 해당 필드에 적용되는 규칙 필터링 및 적용
|
||||
for (const rule of rules) {
|
||||
// 필드 필터 확인
|
||||
if (rule.field && rule.field !== field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ruleStyle: CellFormatStyle = {};
|
||||
|
||||
switch (rule.type) {
|
||||
case "colorScale":
|
||||
ruleStyle = applyColorScale(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "dataBar":
|
||||
ruleStyle = applyDataBar(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "iconSet":
|
||||
ruleStyle = applyIconSet(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "cellValue":
|
||||
ruleStyle = applyCellValue(value, rule);
|
||||
break;
|
||||
}
|
||||
|
||||
// 스타일 병합 (나중 규칙이 우선)
|
||||
resultStyle = { ...resultStyle, ...ruleStyle };
|
||||
}
|
||||
|
||||
return resultStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 서식 스타일을 React 스타일 객체로 변환
|
||||
*/
|
||||
export function formatStyleToReact(
|
||||
style: CellFormatStyle
|
||||
): React.CSSProperties {
|
||||
const result: React.CSSProperties = {};
|
||||
|
||||
if (style.backgroundColor) {
|
||||
result.backgroundColor = style.backgroundColor;
|
||||
}
|
||||
if (style.textColor) {
|
||||
result.color = style.textColor;
|
||||
}
|
||||
if (style.fontWeight) {
|
||||
result.fontWeight = style.fontWeight as any;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default getConditionalStyle;
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Excel 내보내기 유틸리티
|
||||
* 피벗 테이블 데이터를 Excel 파일로 내보내기
|
||||
* xlsx 라이브러리 사용 (브라우저 호환)
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
PivotResult,
|
||||
PivotFieldConfig,
|
||||
PivotTotalsConfig,
|
||||
} from "../types";
|
||||
import { pathToKey } from "./pivotEngine";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface ExportOptions {
|
||||
fileName?: string;
|
||||
sheetName?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
includeHeaders?: boolean;
|
||||
includeTotals?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터를 Excel로 내보내기
|
||||
*/
|
||||
export async function exportPivotToExcel(
|
||||
pivotResult: PivotResult,
|
||||
fields: PivotFieldConfig[],
|
||||
totals: PivotTotalsConfig,
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
fileName = "pivot_export",
|
||||
sheetName = "Pivot",
|
||||
title,
|
||||
includeHeaders = true,
|
||||
includeTotals = true,
|
||||
} = options;
|
||||
|
||||
// 필드 분류
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
// 데이터 배열 생성
|
||||
const data: any[][] = [];
|
||||
|
||||
// 제목 추가
|
||||
if (title) {
|
||||
data.push([title]);
|
||||
data.push([]); // 빈 행
|
||||
}
|
||||
|
||||
// 헤더 행
|
||||
if (includeHeaders) {
|
||||
const headerRow: any[] = [
|
||||
rowFields.map((f) => f.caption).join(" / ") || "항목",
|
||||
];
|
||||
|
||||
// 열 헤더
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
headerRow.push(col.caption || "(전체)");
|
||||
}
|
||||
|
||||
// 총계 헤더
|
||||
if (totals?.showRowGrandTotals && includeTotals) {
|
||||
headerRow.push("총계");
|
||||
}
|
||||
|
||||
data.push(headerRow);
|
||||
}
|
||||
|
||||
// 데이터 행
|
||||
for (const row of pivotResult.flatRows) {
|
||||
const excelRow: any[] = [];
|
||||
|
||||
// 행 헤더 (들여쓰기 포함)
|
||||
const indent = " ".repeat(row.level);
|
||||
excelRow.push(indent + row.caption);
|
||||
|
||||
// 데이터 셀
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = pivotResult.dataMatrix.get(cellKey);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
excelRow.push(values[0].value);
|
||||
} else {
|
||||
excelRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// 행 총계
|
||||
if (totals?.showRowGrandTotals && includeTotals) {
|
||||
const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path));
|
||||
if (rowTotal && rowTotal.length > 0) {
|
||||
excelRow.push(rowTotal[0].value);
|
||||
} else {
|
||||
excelRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
data.push(excelRow);
|
||||
}
|
||||
|
||||
// 열 총계 행
|
||||
if (totals?.showColumnGrandTotals && includeTotals) {
|
||||
const totalRow: any[] = ["총계"];
|
||||
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path));
|
||||
if (colTotal && colTotal.length > 0) {
|
||||
totalRow.push(colTotal[0].value);
|
||||
} else {
|
||||
totalRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// 대총합
|
||||
if (totals?.showRowGrandTotals) {
|
||||
const grandTotal = pivotResult.grandTotals.grand;
|
||||
if (grandTotal && grandTotal.length > 0) {
|
||||
totalRow.push(grandTotal[0].value);
|
||||
} else {
|
||||
totalRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
data.push(totalRow);
|
||||
}
|
||||
|
||||
// 워크시트 생성
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||
|
||||
// 컬럼 너비 설정
|
||||
const colWidths: XLSX.ColInfo[] = [];
|
||||
const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
for (let i = 0; i < maxCols; i++) {
|
||||
colWidths.push({ wch: i === 0 ? 25 : 15 });
|
||||
}
|
||||
worksheet["!cols"] = colWidths;
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill Down 데이터를 Excel로 내보내기
|
||||
*/
|
||||
export async function exportDrillDownToExcel(
|
||||
data: any[],
|
||||
columns: { field: string; caption: string }[],
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
fileName = "drilldown_export",
|
||||
sheetName = "Data",
|
||||
title,
|
||||
} = options;
|
||||
|
||||
// 데이터 배열 생성
|
||||
const sheetData: any[][] = [];
|
||||
|
||||
// 제목
|
||||
if (title) {
|
||||
sheetData.push([title]);
|
||||
sheetData.push([]); // 빈 행
|
||||
}
|
||||
|
||||
// 헤더
|
||||
const headerRow = columns.map((col) => col.caption);
|
||||
sheetData.push(headerRow);
|
||||
|
||||
// 데이터
|
||||
for (const row of data) {
|
||||
const dataRow = columns.map((col) => row[col.field] ?? "");
|
||||
sheetData.push(dataRow);
|
||||
}
|
||||
|
||||
// 워크시트 생성
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(sheetData);
|
||||
|
||||
// 컬럼 너비 설정
|
||||
const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 }));
|
||||
worksheet["!cols"] = colWidths;
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./aggregation";
|
||||
export * from "./pivotEngine";
|
||||
export * from "./exportExcel";
|
||||
export * from "./conditionalFormat";
|
||||
|
||||
|
||||
@@ -1,898 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 데이터 처리 엔진
|
||||
* 원시 데이터를 피벗 구조로 변환합니다.
|
||||
*/
|
||||
|
||||
import {
|
||||
PivotFieldConfig,
|
||||
PivotResult,
|
||||
PivotHeaderNode,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
PivotCellValue,
|
||||
PivotColumnHeaderCell,
|
||||
DateGroupInterval,
|
||||
AggregationType,
|
||||
SummaryDisplayMode,
|
||||
} from "../types";
|
||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||
|
||||
// ==================== 헬퍼 함수 ====================
|
||||
|
||||
/**
|
||||
* 필드 값 추출 (날짜 그룹핑 포함)
|
||||
*/
|
||||
function getFieldValue(
|
||||
row: Record<string, any>,
|
||||
field: PivotFieldConfig
|
||||
): string {
|
||||
const rawValue = row[field.field];
|
||||
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return "(빈 값)";
|
||||
}
|
||||
|
||||
// 날짜 그룹핑 처리
|
||||
if (field.groupInterval && field.dataType === "date") {
|
||||
const date = new Date(rawValue);
|
||||
if (isNaN(date.getTime())) return String(rawValue);
|
||||
|
||||
switch (field.groupInterval) {
|
||||
case "year":
|
||||
return String(date.getFullYear());
|
||||
case "quarter":
|
||||
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
|
||||
case "month":
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
case "week":
|
||||
const weekNum = getWeekNumber(date);
|
||||
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
||||
case "day":
|
||||
return formatDate(date);
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차 계산
|
||||
*/
|
||||
function getWeekNumber(date: Date): number {
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
);
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로를 키로 변환
|
||||
*/
|
||||
export function pathToKey(path: string[]): string {
|
||||
return path.join("||");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 가능한 경로 생성 (열 전체 확장용)
|
||||
*/
|
||||
function generateAllPaths(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[]
|
||||
): string[] {
|
||||
const allPaths: string[] = [];
|
||||
|
||||
// 각 레벨까지의 고유 경로 수집
|
||||
for (let depth = 1; depth <= fields.length; depth++) {
|
||||
const fieldsAtDepth = fields.slice(0, depth);
|
||||
const pathSet = new Set<string>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const path = fieldsAtDepth.map((f) => getFieldValue(row, f));
|
||||
pathSet.add(pathToKey(path));
|
||||
});
|
||||
|
||||
pathSet.forEach((pathKey) => allPaths.push(pathKey));
|
||||
}
|
||||
|
||||
return allPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 키를 경로로 변환
|
||||
*/
|
||||
export function keyToPath(key: string): string[] {
|
||||
return key.split("||");
|
||||
}
|
||||
|
||||
// ==================== 헤더 생성 ====================
|
||||
|
||||
/**
|
||||
* 계층적 헤더 노드 생성
|
||||
*/
|
||||
function buildHeaderTree(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedPaths: Set<string>
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
// 첫 번째 필드로 그룹화
|
||||
const firstField = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, firstField);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
// 정렬
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (firstField.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
// 노드 생성
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: 0,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
// 자식 노드 생성 (확장된 경우만)
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
1
|
||||
);
|
||||
// span 계산
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 노드 재귀 생성
|
||||
*/
|
||||
function buildChildNodes(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
parentPath: string[],
|
||||
expandedPaths: Set<string>,
|
||||
level: number
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
const field = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, field);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (field.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [...parentPath, key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: level,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
level + 1
|
||||
);
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* span 계산 (colspan/rowspan)
|
||||
*/
|
||||
function calculateSpan(children?: PivotHeaderNode[]): number {
|
||||
if (!children || children.length === 0) return 1;
|
||||
return children.reduce((sum, child) => sum + (child.span ?? 1), 0);
|
||||
}
|
||||
|
||||
// ==================== 플랫 구조 변환 ====================
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 행으로 변환
|
||||
*/
|
||||
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
|
||||
const result: PivotFlatRow[] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
result.push({
|
||||
path: node.path,
|
||||
level: node.level,
|
||||
caption: node.caption,
|
||||
isExpanded: node.isExpanded,
|
||||
hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준)
|
||||
});
|
||||
|
||||
if (node.isExpanded && node.children) {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 열로 변환 (각 레벨별)
|
||||
*/
|
||||
function flattenColumns(
|
||||
nodes: PivotHeaderNode[],
|
||||
maxLevel: number
|
||||
): PivotFlatColumn[][] {
|
||||
const levels: PivotFlatColumn[][] = Array.from(
|
||||
{ length: maxLevel + 1 },
|
||||
() => []
|
||||
);
|
||||
|
||||
function traverse(node: PivotHeaderNode, currentLevel: number) {
|
||||
levels[currentLevel].push({
|
||||
path: node.path,
|
||||
level: currentLevel,
|
||||
caption: node.caption,
|
||||
span: node.span ?? 1,
|
||||
});
|
||||
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, currentLevel + 1);
|
||||
}
|
||||
} else if (currentLevel < maxLevel) {
|
||||
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
|
||||
for (let i = currentLevel + 1; i <= maxLevel; i++) {
|
||||
levels[i].push({
|
||||
path: node.path,
|
||||
level: i,
|
||||
caption: "",
|
||||
span: node.span ?? 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 헤더의 최대 깊이 계산
|
||||
*/
|
||||
function getMaxColumnLevel(
|
||||
nodes: PivotHeaderNode[],
|
||||
totalFields: number
|
||||
): number {
|
||||
let maxLevel = 0;
|
||||
|
||||
function traverse(node: PivotHeaderNode, level: number) {
|
||||
maxLevel = Math.max(maxLevel, level);
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return Math.min(maxLevel, totalFields - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 행 열 헤더 생성
|
||||
* 각 레벨별로 셀과 colSpan 정보를 반환
|
||||
*/
|
||||
function buildColumnHeaderLevels(
|
||||
nodes: PivotHeaderNode[],
|
||||
totalLevels: number
|
||||
): PivotColumnHeaderCell[][] {
|
||||
if (totalLevels === 0 || nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const levels: PivotColumnHeaderCell[][] = Array.from(
|
||||
{ length: totalLevels },
|
||||
() => []
|
||||
);
|
||||
|
||||
// 리프 노드 수 계산 (colSpan 계산용)
|
||||
function countLeaves(node: PivotHeaderNode): number {
|
||||
if (!node.children || node.children.length === 0 || !node.isExpanded) {
|
||||
return 1;
|
||||
}
|
||||
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
|
||||
}
|
||||
|
||||
// 트리 순회하며 각 레벨에 셀 추가
|
||||
function traverse(node: PivotHeaderNode, level: number) {
|
||||
const colSpan = countLeaves(node);
|
||||
|
||||
levels[level].push({
|
||||
caption: node.caption,
|
||||
colSpan,
|
||||
path: node.path,
|
||||
level,
|
||||
});
|
||||
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, level + 1);
|
||||
}
|
||||
} else if (level < totalLevels - 1) {
|
||||
// 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움
|
||||
for (let i = level + 1; i < totalLevels; i++) {
|
||||
levels[i].push({
|
||||
caption: "",
|
||||
colSpan,
|
||||
path: node.path,
|
||||
level: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
// ==================== 데이터 매트릭스 생성 ====================
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스 생성
|
||||
*/
|
||||
function buildDataMatrix(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
const matrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 각 셀에 대해 해당하는 데이터 집계
|
||||
for (const row of flatRows) {
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
|
||||
// 해당 행/열 경로에 맞는 데이터 필터링
|
||||
const filteredData = data.filter((record) => {
|
||||
// 행 조건 확인
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
|
||||
// 열 조건 확인
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 데이터 필드별 집계
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(
|
||||
values,
|
||||
dataField.summaryType || "sum"
|
||||
);
|
||||
const formattedValue = formatNumber(
|
||||
aggregatedValue,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
matrix.set(cellKey, cellValues);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 leaf 노드 경로 추출
|
||||
*/
|
||||
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
|
||||
const leaves: string[][] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
if (!node.isExpanded || !node.children || node.children.length === 0) {
|
||||
leaves.push(node.path);
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
// 열 필드가 없을 경우 빈 경로 추가
|
||||
if (leaves.length === 0) {
|
||||
leaves.push([]);
|
||||
}
|
||||
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// ==================== Summary Display Mode 적용 ====================
|
||||
|
||||
/**
|
||||
* Summary Display Mode에 따른 값 변환
|
||||
*/
|
||||
function applyDisplayMode(
|
||||
value: number,
|
||||
displayMode: SummaryDisplayMode | undefined,
|
||||
rowTotal: number,
|
||||
columnTotal: number,
|
||||
grandTotal: number,
|
||||
prevValue: number | null,
|
||||
runningTotal: number,
|
||||
format?: PivotFieldConfig["format"]
|
||||
): { value: number; formattedValue: string } {
|
||||
if (!displayMode || displayMode === "absoluteValue") {
|
||||
return {
|
||||
value,
|
||||
formattedValue: formatNumber(value, format),
|
||||
};
|
||||
}
|
||||
|
||||
let resultValue: number;
|
||||
let formatOverride: PivotFieldConfig["format"] | undefined;
|
||||
|
||||
switch (displayMode) {
|
||||
case "percentOfRowTotal":
|
||||
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfColumnTotal":
|
||||
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfGrandTotal":
|
||||
resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfRowGrandTotal":
|
||||
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfColumnGrandTotal":
|
||||
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "runningTotalByRow":
|
||||
case "runningTotalByColumn":
|
||||
resultValue = runningTotal;
|
||||
break;
|
||||
|
||||
case "differenceFromPrevious":
|
||||
resultValue = prevValue === null ? 0 : value - prevValue;
|
||||
break;
|
||||
|
||||
case "percentDifferenceFromPrevious":
|
||||
resultValue = prevValue === null || prevValue === 0
|
||||
? 0
|
||||
: ((value - prevValue) / Math.abs(prevValue)) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
default:
|
||||
resultValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
value: resultValue,
|
||||
formattedValue: formatNumber(resultValue, formatOverride || format),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스에 Summary Display Mode 적용
|
||||
*/
|
||||
function applyDisplayModeToMatrix(
|
||||
matrix: Map<string, PivotCellValue[]>,
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][],
|
||||
rowTotals: Map<string, PivotCellValue[]>,
|
||||
columnTotals: Map<string, PivotCellValue[]>,
|
||||
grandTotals: PivotCellValue[]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
// displayMode가 있는 데이터 필드가 있는지 확인
|
||||
const hasDisplayMode = dataFields.some(
|
||||
(df) => df.summaryDisplayMode || df.showValuesAs
|
||||
);
|
||||
if (!hasDisplayMode) return matrix;
|
||||
|
||||
const newMatrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 누계를 위한 추적 (행별, 열별)
|
||||
const rowRunningTotals: Map<string, number[]> = new Map(); // fieldIndex -> 누계
|
||||
const colRunningTotals: Map<string, Map<number, number>> = new Map(); // colKey -> fieldIndex -> 누계
|
||||
|
||||
// 행 순서대로 처리
|
||||
for (const row of flatRows) {
|
||||
// 이전 열 값 추적 (차이 계산용)
|
||||
const prevColValues: (number | null)[] = dataFields.map(() => null);
|
||||
|
||||
for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) {
|
||||
const colPath = flatColumnLeaves[colIdx];
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
const values = matrix.get(cellKey);
|
||||
|
||||
if (!values) {
|
||||
newMatrix.set(cellKey, []);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowKey = pathToKey(row.path);
|
||||
const colKey = pathToKey(colPath);
|
||||
|
||||
// 총합 가져오기
|
||||
const rowTotal = rowTotals.get(rowKey);
|
||||
const colTotal = columnTotals.get(colKey);
|
||||
|
||||
const newValues: PivotCellValue[] = values.map((val, fieldIdx) => {
|
||||
const dataField = dataFields[fieldIdx];
|
||||
const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs;
|
||||
|
||||
if (!displayMode || displayMode === "absoluteValue") {
|
||||
prevColValues[fieldIdx] = val.value;
|
||||
return val;
|
||||
}
|
||||
|
||||
// 누계 계산
|
||||
// 행 방향 누계
|
||||
if (!rowRunningTotals.has(rowKey)) {
|
||||
rowRunningTotals.set(rowKey, dataFields.map(() => 0));
|
||||
}
|
||||
const rowRunning = rowRunningTotals.get(rowKey)!;
|
||||
rowRunning[fieldIdx] += val.value || 0;
|
||||
|
||||
// 열 방향 누계
|
||||
if (!colRunningTotals.has(colKey)) {
|
||||
colRunningTotals.set(colKey, new Map());
|
||||
}
|
||||
const colRunning = colRunningTotals.get(colKey)!;
|
||||
if (!colRunning.has(fieldIdx)) {
|
||||
colRunning.set(fieldIdx, 0);
|
||||
}
|
||||
colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0));
|
||||
|
||||
const result = applyDisplayMode(
|
||||
val.value || 0,
|
||||
displayMode,
|
||||
rowTotal?.[fieldIdx]?.value || 0,
|
||||
colTotal?.[fieldIdx]?.value || 0,
|
||||
grandTotals[fieldIdx]?.value || 0,
|
||||
prevColValues[fieldIdx],
|
||||
displayMode === "runningTotalByRow"
|
||||
? rowRunning[fieldIdx]
|
||||
: colRunning.get(fieldIdx) || 0,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
prevColValues[fieldIdx] = val.value;
|
||||
|
||||
return {
|
||||
field: val.field,
|
||||
value: result.value,
|
||||
formattedValue: result.formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
newMatrix.set(cellKey, newValues);
|
||||
}
|
||||
}
|
||||
|
||||
return newMatrix;
|
||||
}
|
||||
|
||||
// ==================== 총합계 계산 ====================
|
||||
|
||||
/**
|
||||
* 총합계 계산
|
||||
*/
|
||||
function calculateGrandTotals(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): {
|
||||
row: Map<string, PivotCellValue[]>;
|
||||
column: Map<string, PivotCellValue[]>;
|
||||
grand: PivotCellValue[];
|
||||
} {
|
||||
const rowTotals = new Map<string, PivotCellValue[]>();
|
||||
const columnTotals = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 행별 총합 (각 행의 모든 열 합계)
|
||||
for (const row of flatRows) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
rowTotals.set(pathToKey(row.path), cellValues);
|
||||
}
|
||||
|
||||
// 열별 총합 (각 열의 모든 행 합계)
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
columnTotals.set(pathToKey(colPath), cellValues);
|
||||
}
|
||||
|
||||
// 대총합
|
||||
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = data.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
row: rowTotals,
|
||||
column: columnTotals,
|
||||
grand: grandValues,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터 처리
|
||||
*/
|
||||
export function processPivotData(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedRowPaths: string[][] = [],
|
||||
expandedColumnPaths: string[][] = []
|
||||
): PivotResult {
|
||||
// 영역별 필드 분리
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const columnFields = fields
|
||||
.filter((f) => f.area === "column" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const dataFields = fields
|
||||
.filter((f) => f.area === "data" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
// 참고: 필터링은 PivotGridComponent에서 이미 처리됨
|
||||
// 여기서는 추가 필터링 없이 전달받은 데이터 사용
|
||||
const filteredData = data;
|
||||
|
||||
// 확장 경로 Set 변환 (잘못된 형식 필터링)
|
||||
const validRowPaths = (expandedRowPaths || []).filter(
|
||||
(p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string")
|
||||
);
|
||||
const validColPaths = (expandedColumnPaths || []).filter(
|
||||
(p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string")
|
||||
);
|
||||
const expandedRowSet = new Set(validRowPaths.map(pathToKey));
|
||||
const expandedColSet = new Set(validColPaths.map(pathToKey));
|
||||
|
||||
// 기본 확장: 첫 번째 레벨 모두 확장
|
||||
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
|
||||
const firstField = rowFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedRowSet.add(val));
|
||||
}
|
||||
|
||||
// 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음)
|
||||
// 모든 가능한 열 경로를 확장 상태로 설정
|
||||
if (columnFields.length > 0) {
|
||||
const allColumnPaths = generateAllPaths(filteredData, columnFields);
|
||||
allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey));
|
||||
}
|
||||
|
||||
// 헤더 트리 생성
|
||||
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
|
||||
const columnHeaders = buildHeaderTree(
|
||||
filteredData,
|
||||
columnFields,
|
||||
expandedColSet
|
||||
);
|
||||
|
||||
// 플랫 구조 변환
|
||||
const flatRows = flattenRows(rowHeaders);
|
||||
const flatColumnLeaves = getColumnLeaves(columnHeaders);
|
||||
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
|
||||
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||
|
||||
// 데이터 매트릭스 생성
|
||||
let dataMatrix = buildDataMatrix(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// 총합계 계산
|
||||
const grandTotals = calculateGrandTotals(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// Summary Display Mode 적용
|
||||
dataMatrix = applyDisplayModeToMatrix(
|
||||
dataMatrix,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves,
|
||||
grandTotals.row,
|
||||
grandTotals.column,
|
||||
grandTotals.grand
|
||||
);
|
||||
|
||||
// 다중 행 열 헤더 생성
|
||||
const columnHeaderLevels = buildColumnHeaderLevels(
|
||||
columnHeaders,
|
||||
columnFields.length
|
||||
);
|
||||
|
||||
return {
|
||||
rowHeaders,
|
||||
columnHeaders,
|
||||
dataMatrix,
|
||||
flatRows,
|
||||
flatColumns: flatColumnLeaves.map((path, idx) => ({
|
||||
path,
|
||||
level: path.length - 1,
|
||||
caption: path[path.length - 1] || "",
|
||||
span: 1,
|
||||
})),
|
||||
columnHeaderLevels,
|
||||
grandTotals,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,732 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardDisplayConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
}
|
||||
|
||||
interface EntityJoinColumn {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}
|
||||
|
||||
interface JoinTable {
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: {
|
||||
sourceColumn: string;
|
||||
};
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 설정 패널
|
||||
* 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원
|
||||
*/
|
||||
export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
tableColumns = [],
|
||||
}) => {
|
||||
// 테이블 선택 상태
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 엔티티 조인 컬럼 상태
|
||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||
availableColumns: EntityJoinColumn[];
|
||||
joinTables: JoinTable[];
|
||||
}>({ availableColumns: [], joinTables: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 현재 사용할 테이블명
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 커스텀 테이블이 아니면 props로 받은 tableColumns 사용
|
||||
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
|
||||
setAvailableColumns(tableColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data?.columns) {
|
||||
setAvailableColumns(result.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [targetTableName, config.useCustomTable, tableColumns]);
|
||||
|
||||
// 엔티티 조인 컬럼 정보 가져오기
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
|
||||
setEntityJoinColumns({
|
||||
availableColumns: result.availableColumns || [],
|
||||
joinTables: result.joinTables || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEntityJoinColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableSelect = (tableName: string, isScreenTable: boolean) => {
|
||||
if (isScreenTable) {
|
||||
// 화면 기본 테이블 선택
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: false,
|
||||
customTableName: undefined,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
|
||||
});
|
||||
} else {
|
||||
// 다른 테이블 선택
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: true,
|
||||
customTableName: tableName,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
|
||||
});
|
||||
}
|
||||
setTableComboboxOpen(false);
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블 표시명 가져오기
|
||||
const getSelectedTableDisplay = () => {
|
||||
if (!targetTableName) return "테이블을 선택하세요";
|
||||
const found = allTables.find(t => t.tableName === targetTableName);
|
||||
return found?.displayName || targetTableName;
|
||||
};
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
const handleNestedChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
let newConfig = { ...config };
|
||||
let current = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트
|
||||
const handleColumnSelect = (path: string, columnName: string) => {
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === columnName
|
||||
);
|
||||
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const existingJoinColumn = joinColumnsConfig.find(
|
||||
(jc: any) => jc.columnName === columnName
|
||||
);
|
||||
|
||||
if (!existingJoinColumn) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: {
|
||||
...config.columnMapping,
|
||||
[path.split(".")[1]]: columnName,
|
||||
},
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleNestedChange(path, columnName);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = () => {
|
||||
const currentColumns = config.columnMapping?.displayColumns || [];
|
||||
const newColumns = [...currentColumns, ""];
|
||||
handleNestedChange("columnMapping.displayColumns", newColumns);
|
||||
};
|
||||
|
||||
// 표시 컬럼 삭제
|
||||
const removeDisplayColumn = (index: number) => {
|
||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||
currentColumns.splice(index, 1);
|
||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||
};
|
||||
|
||||
// 표시 컬럼 값 변경
|
||||
const updateDisplayColumn = (index: number, value: string) => {
|
||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||
currentColumns[index] = value;
|
||||
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === value
|
||||
);
|
||||
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const existingJoinColumn = joinColumnsConfig.find(
|
||||
(jc: any) => jc.columnName === value
|
||||
);
|
||||
|
||||
if (!existingJoinColumn) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: {
|
||||
...config.columnMapping,
|
||||
displayColumns: currentColumns,
|
||||
},
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||
};
|
||||
|
||||
// 테이블별로 조인 컬럼 그룹화
|
||||
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
|
||||
entityJoinColumns.availableColumns.forEach((col) => {
|
||||
if (!joinColumnsByTable[col.tableName]) {
|
||||
joinColumnsByTable[col.tableName] = [];
|
||||
}
|
||||
joinColumnsByTable[col.tableName].push(col);
|
||||
});
|
||||
|
||||
// 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props)
|
||||
const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns);
|
||||
|
||||
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChangeHandler: (value: string) => void,
|
||||
placeholder: string = "컬럼을 선택하세요"
|
||||
) => {
|
||||
return (
|
||||
<Select
|
||||
value={value || "__none__"}
|
||||
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 선택 안함 옵션 */}
|
||||
<SelectItem value="__none__" className="text-xs text-muted-foreground">
|
||||
선택 안함
|
||||
</SelectItem>
|
||||
|
||||
{/* 기본 테이블 컬럼 */}
|
||||
{currentTableColumns.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs font-semibold text-muted-foreground">
|
||||
기본 컬럼
|
||||
</SelectLabel>
|
||||
{currentTableColumns.map((column: any) => (
|
||||
<SelectItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{column.columnLabel || column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* 조인 테이블별 컬럼 */}
|
||||
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
|
||||
<SelectGroup key={tableName}>
|
||||
<SelectLabel className="text-xs font-semibold text-primary">
|
||||
{tableName} (조인)
|
||||
</SelectLabel>
|
||||
{columns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.joinAlias}
|
||||
value={col.joinAlias}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.suggestedLabel || col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">카드 디스플레이 설정</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 테이블</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getSelectedTableDisplay()}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
|
||||
{/* 화면 기본 테이블 */}
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => handleTableSelect(screenTableName, true)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTableName === screenTableName && !config.useCustomTable
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-primary" />
|
||||
{allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* 전체 테이블 */}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter(t => t.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => handleTableSelect(table.tableName, false)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.useCustomTable && targetTableName === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{table.displayName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.useCustomTable && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
|
||||
{(currentTableColumns.length > 0 || loadingColumns) && (
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">컬럼 매핑</h5>
|
||||
|
||||
{(loadingEntityJoins || loadingColumns) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타이틀 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.titleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.titleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">서브타이틀 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.subtitleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.descriptionColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">이미지 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.imageColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.imageColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 동적 표시 컬럼 추가 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">표시 컬럼들</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDisplayColumn}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
+ 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{renderColumnSelect(
|
||||
column,
|
||||
(value) => updateDisplayColumn(index, value)
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisplayColumn(index)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
|
||||
"컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 스타일 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">카드 스타일</h5>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">한 행당 카드 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardsPerRow || 3}
|
||||
onChange={(e) => handleChange("cardsPerRow", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">카드 간격 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.cardSpacing || 16}
|
||||
onChange={(e) => handleChange("cardSpacing", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showTitle"
|
||||
checked={config.cardStyle?.showTitle ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showTitle", checked)}
|
||||
/>
|
||||
<Label htmlFor="showTitle" className="text-xs font-normal">
|
||||
타이틀 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showSubtitle"
|
||||
checked={config.cardStyle?.showSubtitle ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showSubtitle", checked)}
|
||||
/>
|
||||
<Label htmlFor="showSubtitle" className="text-xs font-normal">
|
||||
서브타이틀 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDescription"
|
||||
checked={config.cardStyle?.showDescription ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDescription", checked)}
|
||||
/>
|
||||
<Label htmlFor="showDescription" className="text-xs font-normal">
|
||||
설명 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showImage"
|
||||
checked={config.cardStyle?.showImage ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showImage", checked)}
|
||||
/>
|
||||
<Label htmlFor="showImage" className="text-xs font-normal">
|
||||
이미지 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showActions"
|
||||
checked={config.cardStyle?.showActions ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showActions", checked)}
|
||||
/>
|
||||
<Label htmlFor="showActions" className="text-xs font-normal">
|
||||
액션 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 개별 버튼 설정 */}
|
||||
{(config.cardStyle?.showActions ?? true) && (
|
||||
<div className="ml-5 space-y-2 border-l-2 border-muted pl-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showViewButton"
|
||||
checked={config.cardStyle?.showViewButton ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showViewButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showViewButton" className="text-xs font-normal">
|
||||
상세보기 버튼
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showEditButton"
|
||||
checked={config.cardStyle?.showEditButton ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showEditButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showEditButton" className="text-xs font-normal">
|
||||
편집 버튼
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDeleteButton"
|
||||
checked={config.cardStyle?.showDeleteButton ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDeleteButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showDeleteButton" className="text-xs font-normal">
|
||||
삭제 버튼
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명 최대 길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={config.cardStyle?.maxDescriptionLength || 100}
|
||||
onChange={(e) => handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">공통 설정</h5>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="disabled" className="text-xs font-normal">
|
||||
비활성화
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs font-normal">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2CardDisplayDefinition } from "./index";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
|
||||
/**
|
||||
* CardDisplay 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class CardDisplayRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2CardDisplayDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <CardDisplayComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getCardDisplayProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
CardDisplayRenderer.registerSelf();
|
||||
@@ -1,93 +0,0 @@
|
||||
# CardDisplay 컴포넌트
|
||||
|
||||
테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `card-display`
|
||||
- **카테고리**: display
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { CardDisplayComponent } from "@/lib/registry/components/card-display";
|
||||
|
||||
<CardDisplayComponent
|
||||
component={{
|
||||
id: "my-card-display",
|
||||
type: "widget",
|
||||
web_type: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<CardDisplayComponent
|
||||
component={{
|
||||
id: "sample-card-display",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-15
|
||||
- **CLI 명령어**: `node scripts/create-component.js card-display "카드 디스플레이" "테이블 데이터를 카드 형태로 표시하는 컴포넌트" display text`
|
||||
- **경로**: `lib/registry/components/card-display/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/card-display)
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
import { V2CardDisplayConfigPanel } from "@/components/v2/config-panels/V2CardDisplayConfigPanel";
|
||||
import { CardDisplayConfig } from "./types";
|
||||
import { withContainerQuery } from "../../hoc/withContainerQuery";
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 정의
|
||||
* 테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
||||
*/
|
||||
export const V2CardDisplayDefinition = createComponentDefinition({
|
||||
id: "v2-card-display",
|
||||
hidden: true, // Phase E: 통합 컴포넌트로 대체됨
|
||||
name: "카드 디스플레이",
|
||||
name_eng: "CardDisplay Component",
|
||||
description: "테이블 데이터를 카드 형태로 표시하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
web_type: "text",
|
||||
component: withContainerQuery(CardDisplayComponent, "v2-card-display"),
|
||||
default_config: {
|
||||
cardsPerRow: 3, // 기본값 3 (한 행당 카드 수)
|
||||
cardSpacing: 16,
|
||||
cardStyle: {
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showDescription: true,
|
||||
showImage: false,
|
||||
showActions: true,
|
||||
maxDescriptionLength: 100,
|
||||
imagePosition: "top",
|
||||
imageSize: "medium",
|
||||
},
|
||||
columnMapping: {},
|
||||
dataSource: "table",
|
||||
staticData: [],
|
||||
},
|
||||
default_size: { width: 800, height: 400 },
|
||||
config_panel: V2CardDisplayConfigPanel,
|
||||
icon: "Grid3x3",
|
||||
tags: ["card", "display", "table", "grid"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation:
|
||||
"테이블 데이터를 카드 형태로 표시하는 컴포넌트입니다. 레이아웃과 다르게 컴포넌트로서 재사용 가능하며, 다양한 설정이 가능합니다.",
|
||||
});
|
||||
|
||||
// 컴포넌트는 CardDisplayRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { CardDisplayConfig } from "./types";
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 카드 스타일 설정
|
||||
*/
|
||||
export interface CardStyleConfig {
|
||||
showTitle?: boolean;
|
||||
showSubtitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showImage?: boolean;
|
||||
maxDescriptionLength?: number;
|
||||
imagePosition?: "top" | "left" | "right";
|
||||
imageSize?: "small" | "medium" | "large";
|
||||
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
||||
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
||||
showEditButton?: boolean; // 편집 버튼 표시 여부
|
||||
showDeleteButton?: boolean; // 삭제 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 설정
|
||||
*/
|
||||
export interface ColumnMappingConfig {
|
||||
titleColumn?: string;
|
||||
subtitleColumn?: string;
|
||||
descriptionColumn?: string;
|
||||
imageColumn?: string;
|
||||
displayColumns?: string[];
|
||||
actionColumns?: string[]; // 액션 버튼으로 표시할 컬럼들
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface CardDisplayConfig extends ComponentConfig {
|
||||
// 카드 레이아웃 설정
|
||||
cardsPerRow?: number;
|
||||
cardSpacing?: number;
|
||||
|
||||
// 카드 스타일 설정
|
||||
cardStyle?: CardStyleConfig;
|
||||
|
||||
// 컬럼 매핑 설정
|
||||
columnMapping?: ColumnMappingConfig;
|
||||
|
||||
// 컴포넌트별 테이블 설정
|
||||
useCustomTable?: boolean;
|
||||
customTableName?: string;
|
||||
tableName?: string;
|
||||
isReadOnly?: boolean;
|
||||
|
||||
// 테이블 데이터 설정
|
||||
dataSource?: "static" | "table" | "api";
|
||||
tableId?: string;
|
||||
staticData?: any[];
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onCardClick?: (data: any) => void;
|
||||
onCardHover?: (data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface CardDisplayProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: CardDisplayConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
# PivotGrid 컴포넌트 전체 구현 계획
|
||||
|
||||
## 개요
|
||||
DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현
|
||||
|
||||
## 현재 상태: ✅ 모든 기능 구현 완료!
|
||||
|
||||
---
|
||||
|
||||
## 구현된 기능 목록
|
||||
|
||||
### 1. 기본 피벗 테이블 ✅
|
||||
- [x] 피벗 테이블 렌더링
|
||||
- [x] 행/열 확장/축소
|
||||
- [x] 합계/소계 표시
|
||||
- [x] 전체 확장/축소 버튼
|
||||
|
||||
### 2. 필드 패널 (드래그앤드롭) ✅
|
||||
- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터)
|
||||
- [x] 각 영역에 배치된 필드 칩/태그 표시
|
||||
- [x] 필드 제거 버튼 (X)
|
||||
- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용)
|
||||
- [x] 영역 간 필드 이동
|
||||
- [x] 같은 영역 내 순서 변경
|
||||
- [x] 드래그 시 시각적 피드백
|
||||
|
||||
### 3. 필드 선택기 (모달) ✅
|
||||
- [x] 모달 열기/닫기
|
||||
- [x] 사용 가능한 필드 목록
|
||||
- [x] 필드 검색 기능
|
||||
- [x] 필드별 영역 선택 드롭다운
|
||||
- [x] 데이터 타입 아이콘 표시
|
||||
- [x] 집계 함수 선택 (데이터 영역)
|
||||
- [x] 표시 모드 선택 (데이터 영역)
|
||||
|
||||
### 4. 데이터 요약 (누계, % 모드) ✅
|
||||
- [x] 절대값 표시 (기본)
|
||||
- [x] 행 총계 대비 %
|
||||
- [x] 열 총계 대비 %
|
||||
- [x] 전체 총계 대비 %
|
||||
- [x] 행/열 방향 누계
|
||||
- [x] 이전 대비 차이
|
||||
- [x] 이전 대비 % 차이
|
||||
|
||||
### 5. 필터링 ✅
|
||||
- [x] 필터 팝업 컴포넌트 (FilterPopup)
|
||||
- [x] 값 검색 기능
|
||||
- [x] 체크박스 기반 값 선택
|
||||
- [x] 포함/제외 모드
|
||||
- [x] 전체 선택/해제
|
||||
- [x] 선택된 항목 수 표시
|
||||
|
||||
### 6. Drill Down ✅
|
||||
- [x] 셀 더블클릭 시 상세 데이터 모달
|
||||
- [x] 원본 데이터 테이블 표시
|
||||
- [x] 검색 기능
|
||||
- [x] 정렬 기능
|
||||
- [x] 페이지네이션
|
||||
- [x] CSV/Excel 내보내기
|
||||
|
||||
### 7. Virtual Scrolling ✅
|
||||
- [x] useVirtualScroll 훅 (행)
|
||||
- [x] useVirtualColumnScroll 훅 (열)
|
||||
- [x] useVirtual2DScroll 훅 (행+열)
|
||||
- [x] overscan 버퍼 지원
|
||||
|
||||
### 8. Excel 내보내기 ✅
|
||||
- [x] xlsx 라이브러리 사용
|
||||
- [x] 피벗 데이터 Excel 내보내기
|
||||
- [x] Drill Down 데이터 Excel 내보내기
|
||||
- [x] CSV 내보내기 (기본)
|
||||
- [x] 스타일링 (헤더, 데이터, 총계)
|
||||
- [x] 숫자 포맷
|
||||
|
||||
### 9. 차트 통합 ✅
|
||||
- [x] recharts 라이브러리 사용
|
||||
- [x] 막대 차트
|
||||
- [x] 누적 막대 차트
|
||||
- [x] 선 차트
|
||||
- [x] 영역 차트
|
||||
- [x] 파이 차트
|
||||
- [x] 범례 표시
|
||||
- [x] 커스텀 툴팁
|
||||
- [x] 차트 토글 버튼
|
||||
|
||||
### 10. 조건부 서식 (Conditional Formatting) ✅
|
||||
- [x] Color Scale (색상 그라데이션)
|
||||
- [x] Data Bar (데이터 막대)
|
||||
- [x] Icon Set (아이콘)
|
||||
- [x] Cell Value (조건 기반 스타일)
|
||||
- [x] ConfigPanel에서 설정 UI
|
||||
|
||||
### 11. 상태 저장/복원 ✅
|
||||
- [x] usePivotState 훅
|
||||
- [x] localStorage/sessionStorage 지원
|
||||
- [x] 자동 저장 (디바운스)
|
||||
|
||||
### 12. ConfigPanel 고도화 ✅
|
||||
- [x] 데이터 소스 설정 (테이블 선택)
|
||||
- [x] 필드별 영역 설정 (행, 열, 데이터, 필터)
|
||||
- [x] 총계 옵션 설정
|
||||
- [x] 스타일 설정 (테마, 교차 색상 등)
|
||||
- [x] 내보내기 설정 (Excel/CSV)
|
||||
- [x] 차트 설정 UI
|
||||
- [x] 필드 선택기 설정 UI
|
||||
- [x] 조건부 서식 설정 UI
|
||||
- [x] 크기 설정
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pivot-grid/
|
||||
├── components/
|
||||
│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭)
|
||||
│ ├── FieldChooser.tsx # 필드 선택기 모달
|
||||
│ ├── DrillDownModal.tsx # Drill Down 모달
|
||||
│ ├── FilterPopup.tsx # 필터 팝업
|
||||
│ ├── PivotChart.tsx # 차트 컴포넌트
|
||||
│ └── index.ts # 내보내기
|
||||
├── hooks/
|
||||
│ ├── useVirtualScroll.ts # 가상 스크롤 훅
|
||||
│ ├── usePivotState.ts # 상태 저장 훅
|
||||
│ └── index.ts # 내보내기
|
||||
├── utils/
|
||||
│ ├── aggregation.ts # 집계 함수
|
||||
│ ├── pivotEngine.ts # 피벗 엔진
|
||||
│ ├── exportExcel.ts # Excel 내보내기
|
||||
│ ├── conditionalFormat.ts # 조건부 서식
|
||||
│ └── index.ts # 내보내기
|
||||
├── types.ts # 타입 정의
|
||||
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||
├── PivotGridRenderer.tsx # 렌더러
|
||||
├── index.ts # 모듈 내보내기
|
||||
└── PLAN.md # 이 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 후순위 기능 (선택적)
|
||||
|
||||
다음 기능들은 필요 시 추가 구현 가능:
|
||||
|
||||
### 데이터 바인딩 확장
|
||||
- [ ] OLAP Data Source 연동 (복잡)
|
||||
- [ ] GraphQL 연동
|
||||
- [ ] 실시간 데이터 업데이트 (WebSocket)
|
||||
|
||||
### 고급 기능
|
||||
- [ ] 피벗 테이블 병합 (여러 데이터 소스)
|
||||
- [ ] 계산 필드 (커스텀 수식)
|
||||
- [ ] 데이터 정렬 옵션 강화
|
||||
- [ ] 그룹핑 옵션 (날짜 그룹핑 등)
|
||||
|
||||
---
|
||||
|
||||
## 완료일: 2026-01-08
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,799 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 설정 패널 - 간소화 버전
|
||||
*
|
||||
* 피벗 테이블 설정 방법:
|
||||
* 1. 테이블 선택
|
||||
* 2. 컬럼을 드래그하여 행/열/값 영역에 배치
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridComponentConfig,
|
||||
PivotFieldConfig,
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
FieldDataType,
|
||||
PivotStyleConfig,
|
||||
} 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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Rows,
|
||||
Columns,
|
||||
Calculator,
|
||||
X,
|
||||
Plus,
|
||||
GripVertical,
|
||||
Table2,
|
||||
BarChart3,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_comment?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
column_comment?: string;
|
||||
}
|
||||
|
||||
interface PivotGridConfigPanelProps {
|
||||
config: PivotGridComponentConfig;
|
||||
onChange: (config: PivotGridComponentConfig) => void;
|
||||
}
|
||||
|
||||
// DB 타입을 FieldDataType으로 변환
|
||||
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 ColumnChipProps {
|
||||
column: ColumnInfo;
|
||||
isUsed: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ColumnChip: React.FC<ColumnChipProps> = ({ column, isUsed, onClick }) => {
|
||||
const dataType = mapDbTypeToFieldType(column.data_type);
|
||||
const typeColor = {
|
||||
number: "bg-primary/10 text-primary border-primary/20",
|
||||
string: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
date: "bg-purple-100 text-purple-700 border-purple-200",
|
||||
boolean: "bg-amber-100 text-orange-700 border-orange-200",
|
||||
}[dataType];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isUsed}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs border transition-all",
|
||||
isUsed
|
||||
? "bg-muted text-muted-foreground border-muted cursor-not-allowed opacity-50"
|
||||
: cn(typeColor, "hover:shadow-sm cursor-pointer")
|
||||
)}
|
||||
>
|
||||
<span className="font-medium truncate max-w-[120px]">
|
||||
{column.column_comment || column.column_name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 영역 드롭존 컴포넌트 ====================
|
||||
|
||||
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;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
|
||||
area,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
fields,
|
||||
columns,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onUpdateField,
|
||||
color,
|
||||
}) => {
|
||||
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", color)}>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium text-sm">{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-xs 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 bg-background rounded-md px-2 py-1.5 border"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="flex-1 text-xs font-medium truncate">
|
||||
{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="text-xs text-muted-foreground text-center py-2 border border-dashed rounded-md">
|
||||
아래에서 컬럼을 선택하세요
|
||||
</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="h-3 w-3 mr-1" />
|
||||
<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-muted-foreground text-xs">
|
||||
({mapDbTypeToFieldType(col.data_type)})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
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);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
const mappedTables: TableInfo[] = tableList.map((t: any) => ({
|
||||
table_name: t.table_name,
|
||||
table_comment: t.table_label || t.display_name || t.table_name,
|
||||
}));
|
||||
setTables(mappedTables);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} 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);
|
||||
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
|
||||
column_name: c.column_name,
|
||||
data_type: c.data_type || "text",
|
||||
column_comment: c.column_label || c.column_name,
|
||||
}));
|
||||
setColumns(mappedColumns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} 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) => {
|
||||
if (f.area === area && f.areaIndex === index) {
|
||||
return { ...f, ...updates };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
updateConfig({ fields: newFields });
|
||||
};
|
||||
|
||||
// 영역별 필드 가져오기
|
||||
const getFieldsByArea = (area: PivotAreaType) => {
|
||||
return (config.fields || [])
|
||||
.filter(f => f.area === area)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 사용 가이드 */}
|
||||
<div className="bg-primary/10 border border-primary/20 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5" />
|
||||
<div className="text-xs text-primary">
|
||||
<p className="font-medium mb-1">피벗 테이블 설정 방법</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5 text-primary">
|
||||
<li>데이터를 가져올 <strong>테이블</strong>을 선택하세요</li>
|
||||
<li><strong>행 그룹</strong>에 그룹화할 컬럼을 추가하세요 (예: 지역, 부서)</li>
|
||||
<li><strong>열 그룹</strong>에 가로로 펼칠 컬럼을 추가하세요 (예: 월, 분기)</li>
|
||||
<li><strong>값</strong>에 집계할 숫자 컬럼을 추가하세요 (예: 매출, 수량)</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 1: 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-sm font-semibold">STEP 1. 테이블 선택</Label>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={config.dataSource?.tableName || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({
|
||||
dataSource: {
|
||||
...config.dataSource,
|
||||
type: "table",
|
||||
tableName: v === "__none__" ? undefined : v,
|
||||
},
|
||||
fields: [], // 테이블 변경 시 필드 초기화
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_comment || table.table_name}</span>
|
||||
{table.table_comment && (
|
||||
<span className="text-muted-foreground text-xs">({table.table_name})</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* STEP 2: 필드 배치 */}
|
||||
{config.dataSource?.tableName && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-sm font-semibold">STEP 2. 필드 배치</Label>
|
||||
{loadingColumns && <span className="text-xs text-muted-foreground">(컬럼 로딩 중...)</span>}
|
||||
</div>
|
||||
|
||||
{/* 사용 가능한 컬럼 목록 */}
|
||||
{columns.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-lg p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">사용 가능한 컬럼</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{columns.map((col) => {
|
||||
const isUsed = (config.fields || []).some(f => f.field === col.column_name);
|
||||
return (
|
||||
<ColumnChip
|
||||
key={col.column_name}
|
||||
column={col}
|
||||
isUsed={isUsed}
|
||||
onClick={() => {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 영역별 드롭존 */}
|
||||
<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, updates) => handleUpdateField("row", idx, updates)}
|
||||
color="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, updates) => handleUpdateField("column", idx, updates)}
|
||||
color="border-primary/20 bg-primary/10/50"
|
||||
/>
|
||||
|
||||
<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, updates) => handleUpdateField("data", idx, updates)}
|
||||
color="border-amber-200 bg-amber-50/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 설정 토글 */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>고급 설정</span>
|
||||
</div>
|
||||
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
{/* 표시 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">표시 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">행 총계 위치</Label>
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 총계 위치</Label>
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<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 p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 소계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">줄무늬</Label>
|
||||
<Switch
|
||||
checked={config.style?.alternateRowColors !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...config.style, alternateRowColors: v } as PivotStyleConfig })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">셀 병합</Label>
|
||||
<Switch
|
||||
checked={config.style?.mergeCells === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...config.style, mergeCells: v } as PivotStyleConfig })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">CSV 내보내기</Label>
|
||||
<Switch
|
||||
checked={config.exportConfig?.excel === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">상태 저장</Label>
|
||||
<Switch
|
||||
checked={(config as any).saveState === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ saveState: v } as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">크기 설정</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
value={config.height || ""}
|
||||
onChange={(e) => updateConfig({ height: e.target.value })}
|
||||
placeholder="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 className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">조건부 서식</Label>
|
||||
<div className="space-y-2">
|
||||
{(config.style?.conditionalFormats || []).map((rule, index) => (
|
||||
<div key={rule.id} className="flex items-center gap-2 p-2 rounded-md bg-muted/30">
|
||||
<Select
|
||||
value={rule.type}
|
||||
onValueChange={(v) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, type: v as any };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } as PivotStyleConfig });
|
||||
}}
|
||||
>
|
||||
<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 } as PivotStyleConfig });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
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 } as PivotStyleConfig });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
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 } as PivotStyleConfig });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
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 } as PivotStyleConfig });
|
||||
}}
|
||||
>
|
||||
<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="h-6 w-6 ml-auto"
|
||||
onClick={() => {
|
||||
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } as PivotStyleConfig });
|
||||
}}
|
||||
>
|
||||
<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 } as PivotStyleConfig });
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조건부 서식 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotGridConfigPanel;
|
||||
@@ -1,366 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { PivotGridComponent } from "./PivotGridComponent";
|
||||
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||
import { PivotFieldConfig } from "./types";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
|
||||
// ==================== 샘플 데이터 (미리보기용) ====================
|
||||
|
||||
const SAMPLE_DATA = [
|
||||
{ region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 },
|
||||
];
|
||||
|
||||
const SAMPLE_FIELDS: PivotFieldConfig[] = [
|
||||
{
|
||||
field: "region",
|
||||
caption: "지역",
|
||||
area: "row",
|
||||
areaIndex: 0,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "product",
|
||||
caption: "제품",
|
||||
area: "row",
|
||||
areaIndex: 1,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "quarter",
|
||||
caption: "분기",
|
||||
area: "column",
|
||||
areaIndex: 0,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "sales",
|
||||
caption: "매출",
|
||||
area: "data",
|
||||
areaIndex: 0,
|
||||
dataType: "number",
|
||||
summaryType: "sum",
|
||||
format: { type: "number", precision: 0 },
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입)
|
||||
*/
|
||||
const PivotGridWrapper: React.FC<any> = (props) => {
|
||||
// 컴포넌트 설정에서 값 추출
|
||||
const componentConfig = props.componentConfig || props.config || {};
|
||||
const configFields = componentConfig.fields || props.fields;
|
||||
const configData = props.data;
|
||||
|
||||
// 🆕 테이블에서 데이터 자동 로딩
|
||||
const [loadedData, setLoadedData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTableData = async () => {
|
||||
const tableName = componentConfig.dataSource?.tableName;
|
||||
|
||||
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음
|
||||
if (configData || !tableName || props.isDesignMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName);
|
||||
|
||||
const response = await dataApi.getTableData(tableName, {
|
||||
page: 1,
|
||||
size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size)
|
||||
});
|
||||
|
||||
console.log("🔷 [PivotGrid] API 응답:", response);
|
||||
|
||||
// dataApi.getTableData는 { data, total, page, size, totalPages } 구조
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setLoadedData(response.data);
|
||||
console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건");
|
||||
} else {
|
||||
console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음");
|
||||
setLoadedData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [PivotGrid] 데이터 로딩 에러:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTableData();
|
||||
}, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]);
|
||||
|
||||
// 디버깅 로그
|
||||
console.log("🔷 PivotGridWrapper props:", {
|
||||
isDesignMode: props.isDesignMode,
|
||||
isInteractive: props.isInteractive,
|
||||
hasComponentConfig: !!props.componentConfig,
|
||||
hasConfig: !!props.config,
|
||||
hasData: !!configData,
|
||||
dataLength: configData?.length,
|
||||
hasLoadedData: loadedData.length > 0,
|
||||
loadedDataLength: loadedData.length,
|
||||
hasFields: !!configFields,
|
||||
fieldsLength: configFields?.length,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
// 디자인 모드 판단:
|
||||
// 1. isDesignMode === true
|
||||
// 2. isInteractive === false (편집 모드)
|
||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||
|
||||
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
|
||||
const actualData = configData || loadedData;
|
||||
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
|
||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||
|
||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||
const usePreviewData = isDesignMode || (!hasValidData && !isLoading);
|
||||
|
||||
// 최종 데이터/필드 결정
|
||||
const finalData = usePreviewData ? SAMPLE_DATA : actualData;
|
||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||
const finalTitle = usePreviewData
|
||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||
: (componentConfig.title || props.title);
|
||||
|
||||
console.log("🔷 PivotGridWrapper final:", {
|
||||
isDesignMode,
|
||||
usePreviewData,
|
||||
finalDataLength: finalData?.length,
|
||||
finalFieldsLength: finalFields?.length,
|
||||
});
|
||||
|
||||
// 총계 설정
|
||||
const totalsConfig = componentConfig.totals || props.totals || {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
};
|
||||
|
||||
// 🆕 로딩 중 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PivotGridComponent
|
||||
title={finalTitle}
|
||||
data={finalData}
|
||||
fields={finalFields}
|
||||
totals={totalsConfig}
|
||||
style={componentConfig.style || props.style}
|
||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||
chart={componentConfig.chart || props.chart}
|
||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||
height={componentConfig.height || props.height || "400px"}
|
||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||
onCellClick={props.onCellClick}
|
||||
onCellDoubleClick={props.onCellDoubleClick}
|
||||
onFieldDrop={props.onFieldDrop}
|
||||
onExpandChange={props.onExpandChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PivotGrid 컴포넌트 정의
|
||||
*/
|
||||
const V2PivotGridDefinition = createComponentDefinition({
|
||||
id: "v2-pivot-grid",
|
||||
name: "피벗 그리드",
|
||||
name_eng: "PivotGrid Component",
|
||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
web_type: "text",
|
||||
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
|
||||
default_config: {
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "",
|
||||
},
|
||||
fields: SAMPLE_FIELDS,
|
||||
// 미리보기용 샘플 데이터
|
||||
sampleData: SAMPLE_DATA,
|
||||
totals: {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
},
|
||||
style: {
|
||||
theme: "default",
|
||||
headerStyle: "default",
|
||||
cellPadding: "normal",
|
||||
borderStyle: "light",
|
||||
alternateRowColors: true,
|
||||
highlightTotals: true,
|
||||
},
|
||||
allowExpandAll: true,
|
||||
exportConfig: {
|
||||
excel: true,
|
||||
},
|
||||
height: "400px",
|
||||
},
|
||||
default_size: { width: 800, height: 500 },
|
||||
config_panel: PivotGridConfigPanel,
|
||||
icon: "BarChart3",
|
||||
tags: ["피벗", "분석", "집계", "그리드", "데이터"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* PivotGrid 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2PivotGridDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const props = this.props as any;
|
||||
|
||||
// 컴포넌트 설정에서 값 추출
|
||||
const componentConfig = props.componentConfig || props.config || {};
|
||||
const configFields = componentConfig.fields || props.fields;
|
||||
const configData = props.data;
|
||||
|
||||
// 디버깅 로그
|
||||
console.log("🔷 PivotGridRenderer props:", {
|
||||
isDesignMode: props.isDesignMode,
|
||||
isInteractive: props.isInteractive,
|
||||
hasComponentConfig: !!props.componentConfig,
|
||||
hasConfig: !!props.config,
|
||||
hasData: !!configData,
|
||||
dataLength: configData?.length,
|
||||
hasFields: !!configFields,
|
||||
fieldsLength: configFields?.length,
|
||||
});
|
||||
|
||||
// 디자인 모드 판단:
|
||||
// 1. isDesignMode === true
|
||||
// 2. isInteractive === false (편집 모드)
|
||||
// 3. 데이터가 없는 경우
|
||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
|
||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||
|
||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||
const usePreviewData = isDesignMode || !hasValidData;
|
||||
|
||||
// 최종 데이터/필드 결정
|
||||
const finalData = usePreviewData ? SAMPLE_DATA : configData;
|
||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||
const finalTitle = usePreviewData
|
||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||
: (componentConfig.title || props.title);
|
||||
|
||||
console.log("🔷 PivotGridRenderer final:", {
|
||||
isDesignMode,
|
||||
usePreviewData,
|
||||
finalDataLength: finalData?.length,
|
||||
finalFieldsLength: finalFields?.length,
|
||||
});
|
||||
|
||||
// 총계 설정
|
||||
const totalsConfig = componentConfig.totals || props.totals || {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<PivotGridComponent
|
||||
title={finalTitle}
|
||||
data={finalData}
|
||||
fields={finalFields}
|
||||
totals={totalsConfig}
|
||||
style={componentConfig.style || props.style}
|
||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||
chart={componentConfig.chart || props.chart}
|
||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||
height={componentConfig.height || props.height || "400px"}
|
||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||
onCellClick={props.onCellClick}
|
||||
onCellDoubleClick={props.onCellDoubleClick}
|
||||
onFieldDrop={props.onFieldDrop}
|
||||
onExpandChange={props.onExpandChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
PivotGridRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
PivotGridRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("❌ PivotGrid 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
# PivotGrid 컴포넌트
|
||||
|
||||
다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 다차원 데이터 배치
|
||||
|
||||
- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시)
|
||||
- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기)
|
||||
- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량)
|
||||
- **필터 영역(Filter Area)**: 전체 데이터 필터링
|
||||
|
||||
### 2. 집계 함수
|
||||
|
||||
| 함수 | 설명 | 사용 예 |
|
||||
|------|------|---------|
|
||||
| `sum` | 합계 | 매출 합계 |
|
||||
| `count` | 개수 | 건수 |
|
||||
| `avg` | 평균 | 평균 단가 |
|
||||
| `min` | 최소값 | 최저가 |
|
||||
| `max` | 최대값 | 최고가 |
|
||||
| `countDistinct` | 고유값 개수 | 거래처 수 |
|
||||
|
||||
### 3. 날짜 그룹화
|
||||
|
||||
날짜 필드를 다양한 단위로 그룹화할 수 있습니다:
|
||||
|
||||
- `year`: 연도별
|
||||
- `quarter`: 분기별
|
||||
- `month`: 월별
|
||||
- `week`: 주별
|
||||
- `day`: 일별
|
||||
|
||||
### 4. 드릴다운
|
||||
|
||||
계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다.
|
||||
|
||||
### 5. 총합계/소계
|
||||
|
||||
- 행 총합계 (Row Grand Total)
|
||||
- 열 총합계 (Column Grand Total)
|
||||
- 행 소계 (Row Subtotal)
|
||||
- 열 소계 (Column Subtotal)
|
||||
|
||||
### 6. 내보내기
|
||||
|
||||
CSV 형식으로 데이터를 내보낼 수 있습니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용
|
||||
|
||||
```tsx
|
||||
import { PivotGridComponent } from "@/lib/registry/components/pivot-grid";
|
||||
|
||||
const salesData = [
|
||||
{ region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 },
|
||||
{ region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 },
|
||||
// ...
|
||||
];
|
||||
|
||||
<PivotGridComponent
|
||||
title="매출 분석"
|
||||
data={salesData}
|
||||
fields={[
|
||||
{ field: "region", caption: "지역", area: "row", areaIndex: 0 },
|
||||
{ field: "city", caption: "도시", area: "row", areaIndex: 1 },
|
||||
{ field: "year", caption: "연도", area: "column", areaIndex: 0 },
|
||||
{ field: "quarter", caption: "분기", area: "column", areaIndex: 1 },
|
||||
{ field: "amount", caption: "매출액", area: "data", summaryType: "sum" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 날짜 그룹화
|
||||
|
||||
```tsx
|
||||
<PivotGridComponent
|
||||
data={orderData}
|
||||
fields={[
|
||||
{ field: "customer", caption: "거래처", area: "row" },
|
||||
{
|
||||
field: "orderDate",
|
||||
caption: "주문일",
|
||||
area: "column",
|
||||
dataType: "date",
|
||||
groupInterval: "month", // 월별 그룹화
|
||||
},
|
||||
{ field: "totalAmount", caption: "주문금액", area: "data", summaryType: "sum" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 포맷 설정
|
||||
|
||||
```tsx
|
||||
<PivotGridComponent
|
||||
data={salesData}
|
||||
fields={[
|
||||
{ field: "region", caption: "지역", area: "row" },
|
||||
{ field: "year", caption: "연도", area: "column" },
|
||||
{
|
||||
field: "amount",
|
||||
caption: "매출액",
|
||||
area: "data",
|
||||
summaryType: "sum",
|
||||
format: {
|
||||
type: "currency",
|
||||
prefix: "₩",
|
||||
thousandSeparator: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "ratio",
|
||||
caption: "비율",
|
||||
area: "data",
|
||||
summaryType: "avg",
|
||||
format: {
|
||||
type: "percent",
|
||||
precision: 1,
|
||||
suffix: "%",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 화면 관리에서 사용
|
||||
|
||||
설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다.
|
||||
|
||||
```tsx
|
||||
import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid";
|
||||
|
||||
<PivotGridRenderer
|
||||
id="pivot1"
|
||||
config={{
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "sales_data",
|
||||
},
|
||||
fields: [...],
|
||||
totals: {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
},
|
||||
exportConfig: {
|
||||
excel: true,
|
||||
},
|
||||
}}
|
||||
autoFilter={{ companyCode: "COMPANY_A" }}
|
||||
/>
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### PivotGridProps
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `string` | - | 피벗 테이블 제목 |
|
||||
| `data` | `any[]` | `[]` | 원본 데이터 배열 |
|
||||
| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 |
|
||||
| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 |
|
||||
| `style` | `PivotStyleConfig` | - | 스타일 설정 |
|
||||
| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 |
|
||||
| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 |
|
||||
| `height` | `string | number` | `"auto"` | 높이 |
|
||||
| `maxHeight` | `string` | - | 최대 높이 |
|
||||
|
||||
### PivotFieldConfig
|
||||
|
||||
| 속성 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `field` | `string` | O | 데이터 필드명 |
|
||||
| `caption` | `string` | O | 표시 라벨 |
|
||||
| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 |
|
||||
| `areaIndex` | `number` | - | 영역 내 순서 |
|
||||
| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 |
|
||||
| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) |
|
||||
| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 |
|
||||
| `format` | `PivotFieldFormat` | - | 값 포맷 |
|
||||
| `visible` | `boolean` | - | 표시 여부 |
|
||||
|
||||
### PivotTotalsConfig
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 |
|
||||
| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 |
|
||||
| `showRowTotals` | `boolean` | `true` | 행 소계 표시 |
|
||||
| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pivot-grid/
|
||||
├── index.ts # 모듈 진입점
|
||||
├── types.ts # 타입 정의
|
||||
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||
├── PivotGridRenderer.tsx # 화면 관리 렌더러
|
||||
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||
├── README.md # 문서
|
||||
└── utils/
|
||||
├── index.ts # 유틸리티 모듈 진입점
|
||||
├── aggregation.ts # 집계 함수
|
||||
└── pivotEngine.ts # 피벗 데이터 처리 엔진
|
||||
```
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
### 1. 매출 분석
|
||||
|
||||
지역별/기간별/제품별 매출 현황을 분석합니다.
|
||||
|
||||
### 2. 재고 현황
|
||||
|
||||
창고별/품목별 재고 수량을 한눈에 파악합니다.
|
||||
|
||||
### 3. 생산 실적
|
||||
|
||||
생산라인별/일자별 생산량을 분석합니다.
|
||||
|
||||
### 4. 비용 분석
|
||||
|
||||
부서별/계정별 비용을 집계하여 분석합니다.
|
||||
|
||||
### 5. 수주 현황
|
||||
|
||||
거래처별/품목별/월별 수주 현황을 분석합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요.
|
||||
2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다.
|
||||
3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요.
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 컨텍스트 메뉴 컴포넌트
|
||||
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
ArrowUpAZ,
|
||||
ArrowDownAZ,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { PivotFieldConfig, AggregationType } from "../types";
|
||||
|
||||
interface PivotContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
// 현재 컨텍스트 정보
|
||||
cellType: "header" | "data" | "rowHeader" | "columnHeader";
|
||||
field?: PivotFieldConfig;
|
||||
rowPath?: string[];
|
||||
columnPath?: string[];
|
||||
value?: any;
|
||||
// 콜백
|
||||
onSort?: (field: string, direction: "asc" | "desc") => void;
|
||||
onFilter?: (field: string) => void;
|
||||
onExpand?: (path: string[]) => void;
|
||||
onCollapse?: (path: string[]) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
onCopy?: (value: any) => void;
|
||||
onHideField?: (field: string) => void;
|
||||
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
|
||||
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
|
||||
}
|
||||
|
||||
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
|
||||
children,
|
||||
cellType,
|
||||
field,
|
||||
rowPath,
|
||||
columnPath,
|
||||
value,
|
||||
onSort,
|
||||
onFilter,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
onCopy,
|
||||
onHideField,
|
||||
onChangeSummary,
|
||||
onDrillDown,
|
||||
}) => {
|
||||
const handleCopy = () => {
|
||||
if (value !== undefined && value !== null) {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
onCopy?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{/* 정렬 옵션 (헤더에서만) */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
정렬
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
오름차순
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
|
||||
<ArrowDownAZ className="mr-2 h-4 w-4" />
|
||||
내림차순
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확장/축소 옵션 */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && (
|
||||
<>
|
||||
{rowPath && rowPath.length > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
축소
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={onExpandAll}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
전체 확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onCollapseAll}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
전체 축소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필터 옵션 */}
|
||||
{field && onFilter && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onFilter(field.field)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
필터
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 집계 함수 변경 (데이터 필드에서만) */}
|
||||
{cellType === "data" && field && onChangeSummary && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
집계 함수
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "sum")}
|
||||
>
|
||||
합계
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "count")}
|
||||
>
|
||||
개수
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "avg")}
|
||||
>
|
||||
평균
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "min")}
|
||||
>
|
||||
최소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "max")}
|
||||
>
|
||||
최대
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 드릴다운 (데이터 셀에서만) */}
|
||||
{cellType === "data" && rowPath && columnPath && onDrillDown && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세 데이터 보기
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필드 숨기기 */}
|
||||
{field && onHideField && (
|
||||
<ContextMenuItem onClick={() => onHideField(field.field)}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
필드 숨기기
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{/* 복사 */}
|
||||
<ContextMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotContextMenu;
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DrillDownModal 컴포넌트
|
||||
* 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotCellData, PivotFieldConfig } from "../types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface DrillDownModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cellData: PivotCellData | null;
|
||||
data: any[]; // 전체 원본 데이터
|
||||
fields: PivotFieldConfig[];
|
||||
rowFields: PivotFieldConfig[];
|
||||
columnFields: PivotFieldConfig[];
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const DrillDownModal: React.FC<DrillDownModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
cellData,
|
||||
data,
|
||||
fields,
|
||||
rowFields,
|
||||
columnFields,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
|
||||
|
||||
// 드릴다운 데이터 필터링
|
||||
const filteredData = useMemo(() => {
|
||||
if (!cellData || !data) return [];
|
||||
|
||||
// 행/열 경로에 해당하는 데이터 필터링
|
||||
let result = data.filter((row) => {
|
||||
// 행 경로 매칭
|
||||
for (let i = 0; i < cellData.rowPath.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (field && String(row[field.field]) !== cellData.rowPath[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 열 경로 매칭
|
||||
for (let i = 0; i < cellData.columnPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (field && String(row[field.field]) !== cellData.columnPath[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 검색 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((row) =>
|
||||
Object.values(row).some((val) =>
|
||||
String(val).toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortConfig) {
|
||||
result = [...result].sort((a, b) => {
|
||||
const aVal = a[sortConfig.field];
|
||||
const bVal = b[sortConfig.field];
|
||||
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
comparison = aVal - bVal;
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return sortConfig.direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, currentPage, pageSize]);
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
const displayColumns = useMemo(() => {
|
||||
// 모든 필드의 field명 수집
|
||||
const fieldNames = new Set<string>();
|
||||
|
||||
// fields에서 가져오기
|
||||
fields.forEach((f) => fieldNames.add(f.field));
|
||||
|
||||
// 데이터에서 추가 컬럼 가져오기
|
||||
if (data.length > 0) {
|
||||
Object.keys(data[0]).forEach((key) => fieldNames.add(key));
|
||||
}
|
||||
|
||||
return Array.from(fieldNames).map((fieldName) => {
|
||||
const fieldConfig = fields.find((f) => f.field === fieldName);
|
||||
return {
|
||||
field: fieldName,
|
||||
caption: fieldConfig?.caption || fieldName,
|
||||
dataType: fieldConfig?.dataType || "string",
|
||||
};
|
||||
});
|
||||
}, [fields, data]);
|
||||
|
||||
// 정렬 토글
|
||||
const handleSort = (field: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (!prev || prev.field !== field) {
|
||||
return { field, direction: "asc" };
|
||||
}
|
||||
if (prev.direction === "asc") {
|
||||
return { field, direction: "desc" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
// CSV 내보내기
|
||||
const handleExportCSV = () => {
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
const headers = displayColumns.map((c) => c.caption);
|
||||
const rows = filteredData.map((row) =>
|
||||
displayColumns.map((c) => {
|
||||
const val = row[c.field];
|
||||
if (val === null || val === undefined) return "";
|
||||
if (typeof val === "string" && val.includes(",")) {
|
||||
return `"${val}"`;
|
||||
}
|
||||
return String(val);
|
||||
})
|
||||
);
|
||||
|
||||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
||||
|
||||
const blob = new Blob(["\uFEFF" + csv], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// 페이지 변경
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
};
|
||||
|
||||
// 경로 표시
|
||||
const pathDisplay = cellData
|
||||
? [
|
||||
...(cellData.rowPath.length > 0
|
||||
? [`행: ${cellData.rowPath.join(" > ")}`]
|
||||
: []),
|
||||
...(cellData.columnPath.length > 0
|
||||
? [`열: ${cellData.columnPath.join(" > ")}`]
|
||||
: []),
|
||||
].join(" | ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>상세 데이터</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pathDisplay || "선택한 셀의 원본 데이터"}
|
||||
<span className="ml-2 text-primary font-medium">
|
||||
({filteredData.length}건)
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex items-center gap-2 py-2 border-b border-border">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10개씩</SelectItem>
|
||||
<SelectItem value="20">20개씩</SelectItem>
|
||||
<SelectItem value="50">50개씩</SelectItem>
|
||||
<SelectItem value="100">100개씩</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredData.length === 0}
|
||||
className="h-9"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<ScrollArea className="flex-1 -mx-6">
|
||||
<div className="px-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{displayColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.field}
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort(col.field)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{col.caption}</span>
|
||||
{sortConfig?.field === col.field ? (
|
||||
sortConfig.direction === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={displayColumns.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{displayColumns.map((col) => (
|
||||
<TableCell
|
||||
key={col.field}
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
col.dataType === "number" && "text-right tabular-nums"
|
||||
)}
|
||||
>
|
||||
{formatCellValue(row[col.field], col.dataType)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(currentPage - 1) * pageSize + 1} -{" "}
|
||||
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
|
||||
{filteredData.length}건
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="px-3 text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function formatCellValue(value: any, dataType: string): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
if (dataType === "number") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return String(value);
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
if (dataType === "date") {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toLocaleDateString("ko-KR");
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 원본 반환
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default DrillDownModal;
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FieldChooser 컴포넌트
|
||||
* 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Columns,
|
||||
Rows,
|
||||
BarChart3,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Minus,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
ToggleLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface AvailableField {
|
||||
field: string;
|
||||
caption: string;
|
||||
dataType: "string" | "number" | "date" | "boolean";
|
||||
isSelected: boolean;
|
||||
currentArea?: PivotAreaType;
|
||||
}
|
||||
|
||||
interface FieldChooserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
availableFields: AvailableField[];
|
||||
selectedFields: PivotFieldConfig[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
}
|
||||
|
||||
// ==================== 영역 설정 ====================
|
||||
|
||||
const AREA_OPTIONS: {
|
||||
value: PivotAreaType | "none";
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ value: "none", label: "사용 안함", icon: <Minus className="h-3.5 w-3.5" /> },
|
||||
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
|
||||
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
|
||||
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
|
||||
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [
|
||||
{ value: "sum", label: "합계" },
|
||||
{ value: "count", label: "개수" },
|
||||
{ value: "avg", label: "평균" },
|
||||
{ value: "min", label: "최소" },
|
||||
{ value: "max", label: "최대" },
|
||||
{ value: "countDistinct", label: "고유 개수" },
|
||||
];
|
||||
|
||||
const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
||||
{ value: "absoluteValue", label: "절대값" },
|
||||
{ value: "percentOfRowTotal", label: "행 총계 %" },
|
||||
{ value: "percentOfColumnTotal", label: "열 총계 %" },
|
||||
{ value: "percentOfGrandTotal", label: "전체 총계 %" },
|
||||
{ value: "runningTotalByRow", label: "행 누계" },
|
||||
{ value: "runningTotalByColumn", label: "열 누계" },
|
||||
{ value: "differenceFromPrevious", label: "이전 대비 차이" },
|
||||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||
];
|
||||
|
||||
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "그룹 없음" },
|
||||
{ value: "year", label: "년" },
|
||||
{ value: "quarter", label: "분기" },
|
||||
{ value: "month", label: "월" },
|
||||
{ value: "week", label: "주" },
|
||||
{ value: "day", label: "일" },
|
||||
];
|
||||
|
||||
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
string: <Type className="h-3.5 w-3.5" />,
|
||||
number: <Hash className="h-3.5 w-3.5" />,
|
||||
date: <Calendar className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
// ==================== 필드 아이템 ====================
|
||||
|
||||
interface FieldItemProps {
|
||||
field: AvailableField;
|
||||
config?: PivotFieldConfig;
|
||||
onAreaChange: (area: PivotAreaType | "none") => void;
|
||||
onSummaryChange?: (summary: AggregationType) => void;
|
||||
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
|
||||
}
|
||||
|
||||
const FieldItem: React.FC<FieldItemProps> = ({
|
||||
field,
|
||||
config,
|
||||
onAreaChange,
|
||||
onSummaryChange,
|
||||
onDisplayModeChange,
|
||||
}) => {
|
||||
const currentArea = config?.area || "none";
|
||||
const isSelected = currentArea !== "none";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-md border",
|
||||
"transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-primary/30"
|
||||
: "bg-background border-border hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{/* 데이터 타입 아이콘 */}
|
||||
<div className="text-muted-foreground">
|
||||
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* 필드명 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{field.caption}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{field.field}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영역 선택 */}
|
||||
<Select
|
||||
value={currentArea}
|
||||
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AREA_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 집계 함수 선택 (데이터 영역인 경우) */}
|
||||
{currentArea === "data" && onSummaryChange && (
|
||||
<Select
|
||||
value={config?.summaryType || "sum"}
|
||||
onValueChange={(value) => onSummaryChange(value as AggregationType)}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUMMARY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 표시 모드 선택 (데이터 영역인 경우) */}
|
||||
{currentArea === "data" && onDisplayModeChange && (
|
||||
<Select
|
||||
value={config?.summaryDisplayMode || "absoluteValue"}
|
||||
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISPLAY_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
availableFields,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">(
|
||||
"all"
|
||||
);
|
||||
|
||||
// 필터링된 필드 목록
|
||||
const filteredFields = useMemo(() => {
|
||||
let result = availableFields;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
f.caption.toLowerCase().includes(query) ||
|
||||
f.field.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 상태 필터
|
||||
if (filterType === "selected") {
|
||||
result = result.filter((f) =>
|
||||
selectedFields.some((sf) => sf.field === f.field && sf.visible !== false)
|
||||
);
|
||||
} else if (filterType === "unselected") {
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
!selectedFields.some(
|
||||
(sf) => sf.field === f.field && sf.visible !== false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [availableFields, selectedFields, searchQuery, filterType]);
|
||||
|
||||
// 필드 영역 변경
|
||||
const handleAreaChange = (
|
||||
field: AvailableField,
|
||||
area: PivotAreaType | "none"
|
||||
) => {
|
||||
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
||||
|
||||
if (area === "none") {
|
||||
// 필드 제거 또는 숨기기
|
||||
if (existingConfig) {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, visible: false } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
} else {
|
||||
// 필드 추가 또는 영역 변경
|
||||
if (existingConfig) {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field
|
||||
? { ...f, area, visible: true }
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
} else {
|
||||
// 새 필드 추가
|
||||
const newField: PivotFieldConfig = {
|
||||
field: field.field,
|
||||
caption: field.caption,
|
||||
area,
|
||||
dataType: field.dataType,
|
||||
visible: true,
|
||||
summaryType: area === "data" ? "sum" : undefined,
|
||||
areaIndex: selectedFields.filter((f) => f.area === area).length,
|
||||
};
|
||||
onFieldsChange([...selectedFields, newField]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 집계 함수 변경
|
||||
const handleSummaryChange = (
|
||||
field: AvailableField,
|
||||
summaryType: AggregationType
|
||||
) => {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, summaryType } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 표시 모드 변경
|
||||
const handleDisplayModeChange = (
|
||||
field: AvailableField,
|
||||
displayMode: SummaryDisplayMode
|
||||
) => {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 모든 필드 선택 해제
|
||||
const handleClearAll = () => {
|
||||
const newFields = selectedFields.map((f) => ({ ...f, visible: false }));
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
const visible = selectedFields.filter((f) => f.visible !== false);
|
||||
return {
|
||||
total: availableFields.length,
|
||||
selected: visible.length,
|
||||
filter: visible.filter((f) => f.area === "filter").length,
|
||||
row: visible.filter((f) => f.area === "row").length,
|
||||
column: visible.filter((f) => f.area === "column").length,
|
||||
data: visible.filter((f) => f.area === "data").length,
|
||||
};
|
||||
}, [availableFields, selectedFields]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>필드 선택기</DialogTitle>
|
||||
<DialogDescription>
|
||||
피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
|
||||
<span>전체: {stats.total}</span>
|
||||
<span className="text-primary font-medium">
|
||||
선택됨: {stats.selected}
|
||||
</span>
|
||||
<span>필터: {stats.filter}</span>
|
||||
<span>행: {stats.row}</span>
|
||||
<span>열: {stats.column}</span>
|
||||
<span>데이터: {stats.data}</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filterType}
|
||||
onValueChange={(v) =>
|
||||
setFilterType(v as "all" | "selected" | "unselected")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="selected">선택됨</SelectItem>
|
||||
<SelectItem value="unselected">미선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-9"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<ScrollArea className="flex-1 -mx-6 px-6 max-h-[40vh] overflow-y-auto">
|
||||
<div className="space-y-2 py-2">
|
||||
{filteredFields.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filteredFields.map((field) => {
|
||||
const config = selectedFields.find(
|
||||
(f) => f.field === field.field && f.visible !== false
|
||||
);
|
||||
return (
|
||||
<FieldItem
|
||||
key={field.field}
|
||||
field={field}
|
||||
config={config}
|
||||
onAreaChange={(area) => handleAreaChange(field, area)}
|
||||
onSummaryChange={
|
||||
config?.area === "data"
|
||||
? (summary) => handleSummaryChange(field, summary)
|
||||
: undefined
|
||||
}
|
||||
onDisplayModeChange={
|
||||
config?.area === "data"
|
||||
? (mode) => handleDisplayModeChange(field, mode)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldChooser;
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FieldPanel 컴포넌트
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터)
|
||||
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig, PivotAreaType } from "../types";
|
||||
import {
|
||||
X,
|
||||
Filter,
|
||||
Columns,
|
||||
Rows,
|
||||
BarChart3,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface FieldPanelProps {
|
||||
fields: PivotFieldConfig[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
onFieldRemove?: (field: PivotFieldConfig) => void;
|
||||
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
interface FieldChipProps {
|
||||
field: PivotFieldConfig;
|
||||
onRemove: () => void;
|
||||
onSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
}
|
||||
|
||||
interface DroppableAreaProps {
|
||||
area: PivotAreaType;
|
||||
fields: PivotFieldConfig[];
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
onFieldRemove: (field: PivotFieldConfig) => void;
|
||||
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 영역 설정 ====================
|
||||
|
||||
const AREA_CONFIG: Record<
|
||||
PivotAreaType,
|
||||
{ title: string; icon: React.ReactNode; color: string }
|
||||
> = {
|
||||
filter: {
|
||||
title: "필터",
|
||||
icon: <Filter className="h-3.5 w-3.5" />,
|
||||
color: "bg-amber-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800",
|
||||
},
|
||||
column: {
|
||||
title: "열",
|
||||
icon: <Columns className="h-3.5 w-3.5" />,
|
||||
color: "bg-primary/10 border-primary/20 dark:bg-primary/10 dark:border-primary/30",
|
||||
},
|
||||
row: {
|
||||
title: "행",
|
||||
icon: <Rows className="h-3.5 w-3.5" />,
|
||||
color: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800",
|
||||
},
|
||||
data: {
|
||||
title: "데이터",
|
||||
icon: <BarChart3 className="h-3.5 w-3.5" />,
|
||||
color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 필드 칩 (드래그 가능) ====================
|
||||
|
||||
const SortableFieldChip: React.FC<FieldChipProps> = ({
|
||||
field,
|
||||
onRemove,
|
||||
onSettingsChange,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `${field.area}-${field.field}` });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
"bg-background border border-border shadow-sm",
|
||||
"hover:bg-accent/50 transition-colors",
|
||||
isDragging && "opacity-50 shadow-lg"
|
||||
)}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{/* 필드 라벨 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 hover:text-primary">
|
||||
<span className="font-medium">{field.caption}</span>
|
||||
{field.area === "data" && field.summaryType && (
|
||||
<span className="text-muted-foreground">
|
||||
({getSummaryLabel(field.summaryType)})
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{field.area === "data" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "sum" })
|
||||
}
|
||||
>
|
||||
합계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "count" })
|
||||
}
|
||||
>
|
||||
개수
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "avg" })
|
||||
}
|
||||
>
|
||||
평균
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "min" })
|
||||
}
|
||||
>
|
||||
최소
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "max" })
|
||||
}
|
||||
>
|
||||
최대
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({
|
||||
...field,
|
||||
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, visible: false })}
|
||||
>
|
||||
필드 숨기기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 드롭 영역 ====================
|
||||
|
||||
const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||
area,
|
||||
fields,
|
||||
title,
|
||||
icon,
|
||||
onFieldRemove,
|
||||
onFieldSettingsChange,
|
||||
isOver,
|
||||
}) => {
|
||||
const config = AREA_CONFIG[area];
|
||||
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
|
||||
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
|
||||
"transition-colors duration-200",
|
||||
config.color,
|
||||
isOver && "border-primary bg-primary/5"
|
||||
)}
|
||||
data-area={area}
|
||||
>
|
||||
{/* 영역 헤더 */}
|
||||
<div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{areaFields.length > 0 && (
|
||||
<span className="text-[10px] bg-muted px-1 rounded">
|
||||
{areaFields.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1 min-h-[22px]">
|
||||
{areaFields.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground/50 italic">
|
||||
필드를 여기로 드래그
|
||||
</span>
|
||||
) : (
|
||||
areaFields.map((field) => (
|
||||
<SortableFieldChip
|
||||
key={`${area}-${field.field}`}
|
||||
field={field}
|
||||
onRemove={() => onFieldRemove(field)}
|
||||
onSettingsChange={onFieldSettingsChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function getSummaryLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
sum: "합계",
|
||||
count: "개수",
|
||||
avg: "평균",
|
||||
min: "최소",
|
||||
max: "최대",
|
||||
countDistinct: "고유",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onFieldRemove,
|
||||
onFieldSettingsChange,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
if (!over) {
|
||||
setOverArea(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 드롭 영역 감지
|
||||
const overId = over.id as string;
|
||||
const targetArea = overId.split("-")[0] as PivotAreaType;
|
||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||
setOverArea(targetArea);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
setOverArea(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// 필드 정보 파싱
|
||||
const [sourceArea, sourceField] = activeId.split("-") as [
|
||||
PivotAreaType,
|
||||
string
|
||||
];
|
||||
const [targetArea] = overId.split("-") as [PivotAreaType, string];
|
||||
|
||||
// 같은 영역 내 정렬
|
||||
if (sourceArea === targetArea) {
|
||||
const areaFields = fields.filter((f) => f.area === sourceArea);
|
||||
const sourceIndex = areaFields.findIndex((f) => f.field === sourceField);
|
||||
const targetIndex = areaFields.findIndex(
|
||||
(f) => `${f.area}-${f.field}` === overId
|
||||
);
|
||||
|
||||
if (sourceIndex !== targetIndex && targetIndex >= 0) {
|
||||
// 순서 변경
|
||||
const newFields = [...fields];
|
||||
const fieldToMove = newFields.find(
|
||||
(f) => f.field === sourceField && f.area === sourceArea
|
||||
);
|
||||
if (fieldToMove) {
|
||||
fieldToMove.areaIndex = targetIndex;
|
||||
// 다른 필드들 인덱스 조정
|
||||
newFields
|
||||
.filter((f) => f.area === sourceArea && f.field !== sourceField)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
|
||||
.forEach((f, idx) => {
|
||||
f.areaIndex = idx >= targetIndex ? idx + 1 : idx;
|
||||
});
|
||||
}
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 영역으로 이동
|
||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||
const newFields = fields.map((f) => {
|
||||
if (f.field === sourceField && f.area === sourceArea) {
|
||||
return {
|
||||
...f,
|
||||
area: targetArea as PivotAreaType,
|
||||
areaIndex: fields.filter((ff) => ff.area === targetArea).length,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const handleFieldRemove = (field: PivotFieldConfig) => {
|
||||
if (onFieldRemove) {
|
||||
onFieldRemove(field);
|
||||
} else {
|
||||
// 기본 동작: visible을 false로 설정
|
||||
const newFields = fields.map((f) =>
|
||||
f.field === field.field && f.area === field.area
|
||||
? { ...f, visible: false }
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 설정 변경
|
||||
const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => {
|
||||
if (onFieldSettingsChange) {
|
||||
onFieldSettingsChange(updatedField);
|
||||
}
|
||||
const newFields = fields.map((f) =>
|
||||
f.field === updatedField.field && f.area === updatedField.area
|
||||
? updatedField
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 활성 필드 찾기 (드래그 중인 필드)
|
||||
const activeField = activeId
|
||||
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||
: null;
|
||||
|
||||
// 각 영역의 필드 수 계산
|
||||
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
|
||||
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
|
||||
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
|
||||
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{filterCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Filter className="h-3 w-3" />
|
||||
필터 {filterCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Columns className="h-3 w-3" />
|
||||
열 {columnCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Rows className="h-3 w-3" />
|
||||
행 {rowCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
데이터 {dataCount}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
필드 설정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="border-b border-border bg-muted/20 p-2">
|
||||
{/* 4개 영역 배치: 2x2 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 필터 영역 */}
|
||||
<DroppableArea
|
||||
area="filter"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.filter.title}
|
||||
icon={AREA_CONFIG.filter.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "filter"}
|
||||
/>
|
||||
|
||||
{/* 열 영역 */}
|
||||
<DroppableArea
|
||||
area="column"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.column.title}
|
||||
icon={AREA_CONFIG.column.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "column"}
|
||||
/>
|
||||
|
||||
{/* 행 영역 */}
|
||||
<DroppableArea
|
||||
area="row"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.row.title}
|
||||
icon={AREA_CONFIG.row.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "row"}
|
||||
/>
|
||||
|
||||
{/* 데이터 영역 */}
|
||||
<DroppableArea
|
||||
area="data"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.data.title}
|
||||
icon={AREA_CONFIG.data.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "data"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 접기 버튼 */}
|
||||
{onToggleCollapse && (
|
||||
<div className="flex justify-center mt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-5 px-2"
|
||||
>
|
||||
필드 패널 접기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드래그 오버레이 */}
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
"bg-background border border-primary shadow-lg"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">{activeField.caption}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldPanel;
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FilterPopup 컴포넌트
|
||||
* 피벗 필드의 값을 필터링하는 팝업
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig } from "../types";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Check,
|
||||
X,
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface FilterPopupProps {
|
||||
field: PivotFieldConfig;
|
||||
data: any[];
|
||||
onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FilterPopup: React.FC<FilterPopupProps> = ({
|
||||
field,
|
||||
data,
|
||||
onFilterChange,
|
||||
trigger,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedValues, setSelectedValues] = useState<Set<any>>(
|
||||
new Set(field.filterValues || [])
|
||||
);
|
||||
const [filterType, setFilterType] = useState<"include" | "exclude">(
|
||||
field.filterType || "include"
|
||||
);
|
||||
|
||||
// 고유 값 추출
|
||||
const uniqueValues = useMemo(() => {
|
||||
const values = new Set<any>();
|
||||
data.forEach((row) => {
|
||||
const value = row[field.field];
|
||||
if (value !== null && value !== undefined) {
|
||||
values.add(value);
|
||||
}
|
||||
});
|
||||
return Array.from(values).sort((a, b) => {
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
return String(a).localeCompare(String(b), "ko");
|
||||
});
|
||||
}, [data, field.field]);
|
||||
|
||||
// 필터링된 값 목록
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!searchQuery) return uniqueValues;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return uniqueValues.filter((val) =>
|
||||
String(val).toLowerCase().includes(query)
|
||||
);
|
||||
}, [uniqueValues, searchQuery]);
|
||||
|
||||
// 값 토글
|
||||
const handleValueToggle = (value: any) => {
|
||||
const newSelected = new Set(selectedValues);
|
||||
if (newSelected.has(value)) {
|
||||
newSelected.delete(value);
|
||||
} else {
|
||||
newSelected.add(value);
|
||||
}
|
||||
setSelectedValues(newSelected);
|
||||
};
|
||||
|
||||
// 모두 선택
|
||||
const handleSelectAll = () => {
|
||||
setSelectedValues(new Set(filteredValues));
|
||||
};
|
||||
|
||||
// 모두 해제
|
||||
const handleClearAll = () => {
|
||||
setSelectedValues(new Set());
|
||||
};
|
||||
|
||||
// 적용
|
||||
const handleApply = () => {
|
||||
onFilterChange(field, Array.from(selectedValues), filterType);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setSelectedValues(new Set());
|
||||
setFilterType("include");
|
||||
onFilterChange(field, [], "include");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 필터 활성 상태
|
||||
const isFilterActive = field.filterValues && field.filterValues.length > 0;
|
||||
|
||||
// 선택된 항목 수
|
||||
const selectedCount = selectedValues.size;
|
||||
const totalCount = uniqueValues.length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger || (
|
||||
<button
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-accent",
|
||||
isFilterActive && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{field.caption} 필터</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setFilterType("include")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded",
|
||||
filterType === "include"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
포함
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType("exclude")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded",
|
||||
filterType === "exclude"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
제외
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전체 선택/해제 */}
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{selectedCount} / {totalCount} 선택됨
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
전체 선택
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
전체 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 값 목록 */}
|
||||
<ScrollArea className="h-48">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredValues.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredValues.map((value) => (
|
||||
<label
|
||||
key={String(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer",
|
||||
"hover:bg-muted text-sm"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.has(value)}
|
||||
onCheckedChange={() => handleValueToggle(value)}
|
||||
/>
|
||||
<span className="truncate">{String(value)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
({data.filter((r) => r[field.field] === value).length})
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-between p-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPopup;
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotChart 컴포넌트
|
||||
* 피벗 데이터를 차트로 시각화
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types";
|
||||
import { pathToKey } from "../utils/pivotEngine";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface PivotChartProps {
|
||||
pivotResult: PivotResult;
|
||||
config: PivotChartConfig;
|
||||
dataFields: PivotFieldConfig[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ==================== 색상 ====================
|
||||
|
||||
const COLORS = [
|
||||
"#4472C4", // 파랑
|
||||
"#ED7D31", // 주황
|
||||
"#A5A5A5", // 회색
|
||||
"#FFC000", // 노랑
|
||||
"#5B9BD5", // 하늘
|
||||
"#70AD47", // 초록
|
||||
"#264478", // 진한 파랑
|
||||
"#9E480E", // 진한 주황
|
||||
"#636363", // 진한 회색
|
||||
"#997300", // 진한 노랑
|
||||
];
|
||||
|
||||
// ==================== 데이터 변환 ====================
|
||||
|
||||
function transformDataForChart(
|
||||
pivotResult: PivotResult,
|
||||
dataFields: PivotFieldConfig[]
|
||||
): any[] {
|
||||
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||||
|
||||
// 행 기준 차트 데이터 생성
|
||||
return flatRows.map((row) => {
|
||||
const dataPoint: any = {
|
||||
name: row.caption,
|
||||
path: row.path,
|
||||
};
|
||||
|
||||
// 각 열에 대한 데이터 추가
|
||||
flatColumns.forEach((col) => {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = dataMatrix.get(cellKey);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
const columnName = col.caption || "전체";
|
||||
dataPoint[columnName] = values[0].value;
|
||||
}
|
||||
});
|
||||
|
||||
// 총계 추가
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
if (rowTotal && rowTotal.length > 0) {
|
||||
dataPoint["총계"] = rowTotal[0].value;
|
||||
}
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
}
|
||||
|
||||
function transformDataForPie(
|
||||
pivotResult: PivotResult,
|
||||
dataFields: PivotFieldConfig[]
|
||||
): any[] {
|
||||
const { flatRows, grandTotals } = pivotResult;
|
||||
|
||||
return flatRows.map((row, idx) => {
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
return {
|
||||
name: row.caption,
|
||||
value: rowTotal?.[0]?.value || 0,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 차트 컴포넌트 ====================
|
||||
|
||||
const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
|
||||
<p className="text-sm font-medium mb-1">{label}</p>
|
||||
{payload.map((entry: any, idx: number) => (
|
||||
<p key={idx} className="text-xs" style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value?.toLocaleString()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 막대 차트
|
||||
const PivotBarChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
stacked?: boolean;
|
||||
}> = ({ data, columns, height, showLegend, stacked }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="square"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Bar
|
||||
key={col}
|
||||
dataKey={col}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
radius={stacked ? 0 : [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 선 차트
|
||||
const PivotLineChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, columns, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="line"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Line
|
||||
key={col}
|
||||
type="monotone"
|
||||
dataKey={col}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 영역 차트
|
||||
const PivotAreaChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, columns, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="square"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Area
|
||||
key={col}
|
||||
type="monotone"
|
||||
dataKey={col}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 파이 차트
|
||||
const PivotPieChart: React.FC<{
|
||||
data: any[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={height / 3}
|
||||
label={({ name, percent }: any) =>
|
||||
`${name} (${(percent * 100).toFixed(1)}%)`
|
||||
}
|
||||
labelLine
|
||||
>
|
||||
{data.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="circle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotChart: React.FC<PivotChartProps> = ({
|
||||
pivotResult,
|
||||
config,
|
||||
dataFields,
|
||||
className,
|
||||
}) => {
|
||||
// 차트 데이터 변환
|
||||
const chartData = useMemo(() => {
|
||||
if (config.type === "pie") {
|
||||
return transformDataForPie(pivotResult, dataFields);
|
||||
}
|
||||
return transformDataForChart(pivotResult, dataFields);
|
||||
}, [pivotResult, dataFields, config.type]);
|
||||
|
||||
// 열 이름 목록 (파이 차트 제외)
|
||||
const columns = useMemo(() => {
|
||||
if (config.type === "pie" || chartData.length === 0) return [];
|
||||
|
||||
const firstItem = chartData[0];
|
||||
return Object.keys(firstItem).filter(
|
||||
(key) => key !== "name" && key !== "path"
|
||||
);
|
||||
}, [chartData, config.type]);
|
||||
|
||||
const height = config.height || 300;
|
||||
const showLegend = config.showLegend !== false;
|
||||
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-border bg-background p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 차트 렌더링 */}
|
||||
{config.type === "bar" && (
|
||||
<PivotBarChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "stackedBar" && (
|
||||
<PivotBarChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
stacked
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "line" && (
|
||||
<PivotLineChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "area" && (
|
||||
<PivotAreaChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "pie" && (
|
||||
<PivotPieChart
|
||||
data={chartData}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotChart;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 서브 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export { FieldPanel } from "./FieldPanel";
|
||||
export { FieldChooser } from "./FieldChooser";
|
||||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 커스텀 훅 내보내기
|
||||
*/
|
||||
|
||||
export {
|
||||
useVirtualScroll,
|
||||
useVirtualColumnScroll,
|
||||
useVirtual2DScroll,
|
||||
} from "./useVirtualScroll";
|
||||
|
||||
export type {
|
||||
VirtualScrollOptions,
|
||||
VirtualScrollResult,
|
||||
VirtualColumnScrollOptions,
|
||||
VirtualColumnScrollResult,
|
||||
Virtual2DScrollOptions,
|
||||
Virtual2DScrollResult,
|
||||
} from "./useVirtualScroll";
|
||||
|
||||
export { usePivotState } from "./usePivotState";
|
||||
|
||||
export type {
|
||||
PivotStateConfig,
|
||||
SavedPivotState,
|
||||
UsePivotStateResult,
|
||||
} from "./usePivotState";
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotState 훅
|
||||
* 피벗 그리드 상태 저장/복원 관리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { PivotFieldConfig, PivotGridState, SortDirection } from "../types";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface PivotStateConfig {
|
||||
enabled: boolean;
|
||||
storageKey?: string;
|
||||
storageType?: "localStorage" | "sessionStorage";
|
||||
}
|
||||
|
||||
export interface SavedPivotState {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
fields: PivotFieldConfig[];
|
||||
expandedRowPaths: string[][];
|
||||
expandedColumnPaths: string[][];
|
||||
filterConfig: Record<string, any[]>;
|
||||
sortConfig: {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UsePivotStateResult {
|
||||
// 상태
|
||||
fields: PivotFieldConfig[];
|
||||
pivotState: PivotGridState;
|
||||
|
||||
// 상태 변경
|
||||
setFields: (fields: PivotFieldConfig[]) => void;
|
||||
setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void;
|
||||
|
||||
// 저장/복원
|
||||
saveState: () => void;
|
||||
loadState: () => boolean;
|
||||
clearState: () => void;
|
||||
hasStoredState: () => boolean;
|
||||
|
||||
// 상태 정보
|
||||
lastSaved: Date | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
// ==================== 상수 ====================
|
||||
|
||||
const STATE_VERSION = "1.0.0";
|
||||
const DEFAULT_STORAGE_KEY = "pivot-grid-state";
|
||||
|
||||
// ==================== 훅 ====================
|
||||
|
||||
export function usePivotState(
|
||||
initialFields: PivotFieldConfig[],
|
||||
config: PivotStateConfig
|
||||
): UsePivotStateResult {
|
||||
const {
|
||||
enabled,
|
||||
storageKey = DEFAULT_STORAGE_KEY,
|
||||
storageType = "localStorage",
|
||||
} = config;
|
||||
|
||||
// 상태
|
||||
const [fields, setFieldsInternal] = useState<PivotFieldConfig[]>(initialFields);
|
||||
const [pivotState, setPivotStateInternal] = useState<PivotGridState>({
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
sortConfig: null,
|
||||
filterConfig: {},
|
||||
});
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialStateLoaded, setInitialStateLoaded] = useState(false);
|
||||
|
||||
// 스토리지 가져오기
|
||||
const getStorage = useCallback(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return storageType === "localStorage" ? localStorage : sessionStorage;
|
||||
}, [storageType]);
|
||||
|
||||
// 저장된 상태 확인
|
||||
const hasStoredState = useCallback((): boolean => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
return storage.getItem(storageKey) !== null;
|
||||
}, [getStorage, storageKey]);
|
||||
|
||||
// 상태 저장
|
||||
const saveState = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
const stateToSave: SavedPivotState = {
|
||||
version: STATE_VERSION,
|
||||
timestamp: Date.now(),
|
||||
fields,
|
||||
expandedRowPaths: pivotState.expandedRowPaths,
|
||||
expandedColumnPaths: pivotState.expandedColumnPaths,
|
||||
filterConfig: pivotState.filterConfig,
|
||||
sortConfig: pivotState.sortConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(stateToSave));
|
||||
setLastSaved(new Date());
|
||||
setIsDirty(false);
|
||||
console.log("✅ 피벗 상태 저장됨:", storageKey);
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 저장 실패:", error);
|
||||
}
|
||||
}, [enabled, getStorage, storageKey, fields, pivotState]);
|
||||
|
||||
// 상태 불러오기
|
||||
const loadState = useCallback((): boolean => {
|
||||
if (!enabled) return false;
|
||||
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
|
||||
try {
|
||||
const saved = storage.getItem(storageKey);
|
||||
if (!saved) return false;
|
||||
|
||||
const parsedState: SavedPivotState = JSON.parse(saved);
|
||||
|
||||
// 버전 체크
|
||||
if (parsedState.version !== STATE_VERSION) {
|
||||
console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 복원
|
||||
setFieldsInternal(parsedState.fields);
|
||||
setPivotStateInternal({
|
||||
expandedRowPaths: parsedState.expandedRowPaths,
|
||||
expandedColumnPaths: parsedState.expandedColumnPaths,
|
||||
sortConfig: parsedState.sortConfig,
|
||||
filterConfig: parsedState.filterConfig,
|
||||
});
|
||||
setLastSaved(new Date(parsedState.timestamp));
|
||||
setIsDirty(false);
|
||||
|
||||
console.log("✅ 피벗 상태 복원됨:", storageKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 복원 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}, [enabled, getStorage, storageKey]);
|
||||
|
||||
// 상태 초기화
|
||||
const clearState = useCallback(() => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
storage.removeItem(storageKey);
|
||||
setLastSaved(null);
|
||||
console.log("🗑️ 피벗 상태 삭제됨:", storageKey);
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 삭제 실패:", error);
|
||||
}
|
||||
}, [getStorage, storageKey]);
|
||||
|
||||
// 필드 변경 (dirty 플래그 설정)
|
||||
const setFields = useCallback((newFields: PivotFieldConfig[]) => {
|
||||
setFieldsInternal(newFields);
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
// 피벗 상태 변경 (dirty 플래그 설정)
|
||||
const setPivotState = useCallback(
|
||||
(newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => {
|
||||
setPivotStateInternal(newState);
|
||||
setIsDirty(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (!initialStateLoaded && enabled && hasStoredState()) {
|
||||
loadState();
|
||||
setInitialStateLoaded(true);
|
||||
}
|
||||
}, [enabled, hasStoredState, loadState, initialStateLoaded]);
|
||||
|
||||
// 초기 필드 동기화 (저장된 상태가 없을 때)
|
||||
useEffect(() => {
|
||||
if (initialStateLoaded) return;
|
||||
if (!hasStoredState() && initialFields.length > 0) {
|
||||
setFieldsInternal(initialFields);
|
||||
setInitialStateLoaded(true);
|
||||
}
|
||||
}, [initialFields, hasStoredState, initialStateLoaded]);
|
||||
|
||||
// 자동 저장 (변경 시)
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDirty) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
saveState();
|
||||
}, 1000); // 1초 디바운스
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [enabled, isDirty, saveState]);
|
||||
|
||||
return {
|
||||
fields,
|
||||
pivotState,
|
||||
setFields,
|
||||
setPivotState,
|
||||
saveState,
|
||||
loadState,
|
||||
clearState,
|
||||
hasStoredState,
|
||||
lastSaved,
|
||||
isDirty,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePivotState;
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Virtual Scroll 훅
|
||||
* 대용량 피벗 데이터의 가상 스크롤 처리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemCount: number; // 전체 아이템 수
|
||||
itemHeight: number; // 각 아이템 높이 (px)
|
||||
containerHeight: number; // 컨테이너 높이 (px)
|
||||
overscan?: number; // 버퍼 아이템 수 (기본: 5)
|
||||
}
|
||||
|
||||
export interface VirtualScrollResult {
|
||||
// 현재 보여야 할 아이템 범위
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
|
||||
// 가상 스크롤 관련 값
|
||||
totalHeight: number; // 전체 높이
|
||||
offsetTop: number; // 상단 오프셋
|
||||
|
||||
// 보여지는 아이템 목록
|
||||
visibleItems: number[];
|
||||
|
||||
// 이벤트 핸들러
|
||||
onScroll: (scrollTop: number) => void;
|
||||
|
||||
// 컨테이너 ref
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// ==================== 훅 ====================
|
||||
|
||||
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
|
||||
const {
|
||||
itemCount,
|
||||
itemHeight,
|
||||
containerHeight,
|
||||
overscan = 5,
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
// 보이는 아이템 수
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||
|
||||
// 시작/끝 인덱스 계산
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const end = Math.min(
|
||||
itemCount - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
||||
);
|
||||
return { startIndex: start, endIndex: end };
|
||||
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
|
||||
|
||||
// 전체 높이
|
||||
const totalHeight = itemCount * itemHeight;
|
||||
|
||||
// 상단 오프셋
|
||||
const offsetTop = startIndex * itemHeight;
|
||||
|
||||
// 보이는 아이템 인덱스 배열
|
||||
const visibleItems = useMemo(() => {
|
||||
const items: number[] = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
return items;
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
// 스크롤 핸들러
|
||||
const onScroll = useCallback((newScrollTop: number) => {
|
||||
setScrollTop(newScrollTop);
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(container.scrollTop);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
visibleItems,
|
||||
onScroll,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 열 가상 스크롤 ====================
|
||||
|
||||
export interface VirtualColumnScrollOptions {
|
||||
columnCount: number; // 전체 열 수
|
||||
columnWidth: number; // 각 열 너비 (px)
|
||||
containerWidth: number; // 컨테이너 너비 (px)
|
||||
overscan?: number;
|
||||
}
|
||||
|
||||
export interface VirtualColumnScrollResult {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalWidth: number;
|
||||
offsetLeft: number;
|
||||
visibleColumns: number[];
|
||||
onScroll: (scrollLeft: number) => void;
|
||||
}
|
||||
|
||||
export function useVirtualColumnScroll(
|
||||
options: VirtualColumnScrollOptions
|
||||
): VirtualColumnScrollResult {
|
||||
const {
|
||||
columnCount,
|
||||
columnWidth,
|
||||
containerWidth,
|
||||
overscan = 3,
|
||||
} = options;
|
||||
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan);
|
||||
const end = Math.min(
|
||||
columnCount - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan
|
||||
);
|
||||
return { startIndex: start, endIndex: end };
|
||||
}, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]);
|
||||
|
||||
const totalWidth = columnCount * columnWidth;
|
||||
const offsetLeft = startIndex * columnWidth;
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
const cols: number[] = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
cols.push(i);
|
||||
}
|
||||
return cols;
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
const onScroll = useCallback((newScrollLeft: number) => {
|
||||
setScrollLeft(newScrollLeft);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalWidth,
|
||||
offsetLeft,
|
||||
visibleColumns,
|
||||
onScroll,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 2D 가상 스크롤 (행 + 열) ====================
|
||||
|
||||
export interface Virtual2DScrollOptions {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
rowHeight: number;
|
||||
columnWidth: number;
|
||||
containerHeight: number;
|
||||
containerWidth: number;
|
||||
rowOverscan?: number;
|
||||
columnOverscan?: number;
|
||||
}
|
||||
|
||||
export interface Virtual2DScrollResult {
|
||||
// 행 범위
|
||||
rowStartIndex: number;
|
||||
rowEndIndex: number;
|
||||
totalHeight: number;
|
||||
offsetTop: number;
|
||||
visibleRows: number[];
|
||||
|
||||
// 열 범위
|
||||
columnStartIndex: number;
|
||||
columnEndIndex: number;
|
||||
totalWidth: number;
|
||||
offsetLeft: number;
|
||||
visibleColumns: number[];
|
||||
|
||||
// 스크롤 핸들러
|
||||
onScroll: (scrollTop: number, scrollLeft: number) => void;
|
||||
|
||||
// 컨테이너 ref
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useVirtual2DScroll(
|
||||
options: Virtual2DScrollOptions
|
||||
): Virtual2DScrollResult {
|
||||
const {
|
||||
rowCount,
|
||||
columnCount,
|
||||
rowHeight,
|
||||
columnWidth,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
rowOverscan = 5,
|
||||
columnOverscan = 3,
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// 행 계산
|
||||
const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan);
|
||||
const end = Math.min(
|
||||
rowCount - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan
|
||||
);
|
||||
|
||||
const rows: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
rows.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
rowStartIndex: start,
|
||||
rowEndIndex: end,
|
||||
visibleRows: rows,
|
||||
};
|
||||
}, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]);
|
||||
|
||||
// 열 계산
|
||||
const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan);
|
||||
const end = Math.min(
|
||||
columnCount - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan
|
||||
);
|
||||
|
||||
const cols: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
cols.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
columnStartIndex: start,
|
||||
columnEndIndex: end,
|
||||
visibleColumns: cols,
|
||||
};
|
||||
}, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]);
|
||||
|
||||
const totalHeight = rowCount * rowHeight;
|
||||
const totalWidth = columnCount * columnWidth;
|
||||
const offsetTop = rowStartIndex * rowHeight;
|
||||
const offsetLeft = columnStartIndex * columnWidth;
|
||||
|
||||
const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => {
|
||||
setScrollTop(newScrollTop);
|
||||
setScrollLeft(newScrollLeft);
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(container.scrollTop);
|
||||
setScrollLeft(container.scrollLeft);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rowStartIndex,
|
||||
rowEndIndex,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
visibleRows,
|
||||
columnStartIndex,
|
||||
columnEndIndex,
|
||||
totalWidth,
|
||||
offsetLeft,
|
||||
visibleColumns,
|
||||
onScroll,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
|
||||
export default useVirtualScroll;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 컴포넌트 모듈
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
// 기본 타입
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
SummaryDisplayMode,
|
||||
SortDirection,
|
||||
DateGroupInterval,
|
||||
FieldDataType,
|
||||
DataSourceType,
|
||||
// 필드 설정
|
||||
PivotFieldFormat,
|
||||
PivotFieldConfig,
|
||||
// 데이터 소스
|
||||
PivotFilterCondition,
|
||||
PivotJoinConfig,
|
||||
PivotDataSourceConfig,
|
||||
// 표시 설정
|
||||
PivotTotalsConfig,
|
||||
FieldChooserConfig,
|
||||
PivotChartConfig,
|
||||
PivotStyleConfig,
|
||||
PivotExportConfig,
|
||||
// Props
|
||||
PivotGridProps,
|
||||
// 결과 데이터
|
||||
PivotCellData,
|
||||
PivotHeaderNode,
|
||||
PivotCellValue,
|
||||
PivotResult,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
// 상태
|
||||
PivotGridState,
|
||||
// Config
|
||||
PivotGridComponentConfig,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { PivotGridComponent } from "./PivotGridComponent";
|
||||
export { V2PivotGridConfigPanel as PivotGridConfigPanel } from "@/components/v2/config-panels/V2PivotGridConfigPanel";
|
||||
|
||||
// 유틸리티
|
||||
export {
|
||||
aggregate,
|
||||
sum,
|
||||
count,
|
||||
avg,
|
||||
min,
|
||||
max,
|
||||
countDistinct,
|
||||
formatNumber,
|
||||
formatDate,
|
||||
getAggregationLabel,
|
||||
} from "./utils/aggregation";
|
||||
|
||||
export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine";
|
||||
@@ -1,408 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 컴포넌트 타입 정의
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
// ==================== 기본 타입 ====================
|
||||
|
||||
// 필드 영역 타입
|
||||
export type PivotAreaType = "row" | "column" | "data" | "filter";
|
||||
|
||||
// 집계 함수 타입
|
||||
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
||||
|
||||
// 요약 표시 모드
|
||||
export type SummaryDisplayMode =
|
||||
| "absoluteValue" // 절대값 (기본)
|
||||
| "percentOfColumnTotal" // 열 총계 대비 %
|
||||
| "percentOfRowTotal" // 행 총계 대비 %
|
||||
| "percentOfGrandTotal" // 전체 총계 대비 %
|
||||
| "percentOfColumnGrandTotal" // 열 대총계 대비 %
|
||||
| "percentOfRowGrandTotal" // 행 대총계 대비 %
|
||||
| "runningTotalByRow" // 행 방향 누계
|
||||
| "runningTotalByColumn" // 열 방향 누계
|
||||
| "differenceFromPrevious" // 이전 대비 차이
|
||||
| "percentDifferenceFromPrevious"; // 이전 대비 % 차이
|
||||
|
||||
// 정렬 방향
|
||||
export type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
// 날짜 그룹 간격
|
||||
export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day";
|
||||
|
||||
// 필드 데이터 타입
|
||||
export type FieldDataType = "string" | "number" | "date" | "boolean";
|
||||
|
||||
// 데이터 소스 타입
|
||||
export type DataSourceType = "table" | "api" | "static";
|
||||
|
||||
// ==================== 필드 설정 ====================
|
||||
|
||||
// 필드 포맷 설정
|
||||
export interface PivotFieldFormat {
|
||||
type: "number" | "currency" | "percent" | "date" | "text";
|
||||
precision?: number; // 소수점 자릿수
|
||||
thousandSeparator?: boolean; // 천단위 구분자
|
||||
prefix?: string; // 접두사 (예: "$", "₩")
|
||||
suffix?: string; // 접미사 (예: "%", "원")
|
||||
dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD")
|
||||
}
|
||||
|
||||
// 필드 설정
|
||||
export interface PivotFieldConfig {
|
||||
// 기본 정보
|
||||
field: string; // 데이터 필드명
|
||||
caption: string; // 표시 라벨
|
||||
area: PivotAreaType; // 배치 영역
|
||||
areaIndex?: number; // 영역 내 순서
|
||||
|
||||
// 데이터 타입
|
||||
dataType?: FieldDataType; // 데이터 타입
|
||||
|
||||
// 집계 설정 (data 영역용)
|
||||
summaryType?: AggregationType; // 집계 함수
|
||||
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
|
||||
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
|
||||
|
||||
// 정렬 설정
|
||||
sortBy?: "value" | "caption"; // 정렬 기준
|
||||
sortOrder?: SortDirection; // 정렬 방향
|
||||
sortBySummary?: string; // 요약값 기준 정렬 (data 필드명)
|
||||
|
||||
// 날짜 그룹화 설정
|
||||
groupInterval?: DateGroupInterval; // 날짜 그룹 간격
|
||||
groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성)
|
||||
|
||||
// 표시 설정
|
||||
visible?: boolean; // 표시 여부
|
||||
width?: number; // 컬럼 너비
|
||||
expanded?: boolean; // 기본 확장 상태
|
||||
|
||||
// 포맷 설정
|
||||
format?: PivotFieldFormat; // 값 포맷
|
||||
|
||||
// 필터 설정
|
||||
filterValues?: any[]; // 선택된 필터 값
|
||||
filterType?: "include" | "exclude"; // 필터 타입
|
||||
allowFiltering?: boolean; // 필터링 허용
|
||||
allowSorting?: boolean; // 정렬 허용
|
||||
|
||||
// 계층 관련
|
||||
displayFolder?: string; // 필드 선택기에서 폴더 구조
|
||||
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
||||
|
||||
// 계산 필드
|
||||
isCalculated?: boolean; // 계산 필드 여부
|
||||
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
|
||||
}
|
||||
|
||||
// ==================== 데이터 소스 설정 ====================
|
||||
|
||||
// 필터 조건
|
||||
export interface PivotFilterCondition {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
value?: any;
|
||||
valueFromField?: string; // formData에서 값 가져오기
|
||||
}
|
||||
|
||||
// 조인 설정
|
||||
export interface PivotJoinConfig {
|
||||
joinType: "INNER" | "LEFT" | "RIGHT";
|
||||
targetTable: string;
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
columns: string[]; // 가져올 컬럼들
|
||||
}
|
||||
|
||||
// 데이터 소스 설정
|
||||
export interface PivotDataSourceConfig {
|
||||
type: DataSourceType;
|
||||
|
||||
// 테이블 기반
|
||||
tableName?: string; // 테이블명
|
||||
|
||||
// API 기반
|
||||
apiEndpoint?: string; // API 엔드포인트
|
||||
apiMethod?: "GET" | "POST"; // HTTP 메서드
|
||||
|
||||
// 정적 데이터
|
||||
staticData?: any[]; // 정적 데이터
|
||||
|
||||
// 필터 조건
|
||||
filterConditions?: PivotFilterCondition[];
|
||||
|
||||
// 조인 설정
|
||||
joinConfigs?: PivotJoinConfig[];
|
||||
}
|
||||
|
||||
// ==================== 표시 설정 ====================
|
||||
|
||||
// 총합계 표시 설정
|
||||
export interface PivotTotalsConfig {
|
||||
// 행 총합계
|
||||
showRowGrandTotals?: boolean; // 행 총합계 표시
|
||||
showRowTotals?: boolean; // 행 소계 표시
|
||||
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
|
||||
|
||||
// 열 총합계
|
||||
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
||||
showColumnTotals?: boolean; // 열 소계 표시
|
||||
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
|
||||
}
|
||||
|
||||
// 필드 선택기 설정
|
||||
export interface FieldChooserConfig {
|
||||
enabled: boolean; // 활성화 여부
|
||||
allowSearch?: boolean; // 검색 허용
|
||||
layout?: "default" | "simplified"; // 레이아웃
|
||||
height?: number; // 높이
|
||||
applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점
|
||||
}
|
||||
|
||||
// 차트 연동 설정
|
||||
export interface PivotChartConfig {
|
||||
enabled: boolean; // 차트 표시 여부
|
||||
type: "bar" | "line" | "area" | "pie" | "stackedBar";
|
||||
position: "top" | "bottom" | "left" | "right";
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
// 조건부 서식 규칙
|
||||
export interface ConditionalFormatRule {
|
||||
id: string;
|
||||
type: "colorScale" | "dataBar" | "iconSet" | "cellValue";
|
||||
field?: string; // 적용할 데이터 필드 (없으면 전체)
|
||||
|
||||
// colorScale: 값 범위에 따른 색상 그라데이션
|
||||
colorScale?: {
|
||||
minColor: string; // 최소값 색상 (예: "#ff0000")
|
||||
midColor?: string; // 중간값 색상 (선택)
|
||||
maxColor: string; // 최대값 색상 (예: "#00ff00")
|
||||
};
|
||||
|
||||
// dataBar: 값에 따른 막대 표시
|
||||
dataBar?: {
|
||||
color: string; // 막대 색상
|
||||
showValue?: boolean; // 값 표시 여부
|
||||
minValue?: number; // 최소값 (없으면 자동)
|
||||
maxValue?: number; // 최대값 (없으면 자동)
|
||||
};
|
||||
|
||||
// iconSet: 값에 따른 아이콘 표시
|
||||
iconSet?: {
|
||||
type: "arrows" | "traffic" | "rating" | "flags";
|
||||
thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100)
|
||||
reverse?: boolean; // 아이콘 순서 반전
|
||||
};
|
||||
|
||||
// cellValue: 조건에 따른 스타일
|
||||
cellValue?: {
|
||||
operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between";
|
||||
value1: number;
|
||||
value2?: number; // between 연산자용
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
bold?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 스타일 설정
|
||||
export interface PivotStyleConfig {
|
||||
theme: "default" | "compact" | "modern";
|
||||
headerStyle: "default" | "dark" | "light";
|
||||
cellPadding: "compact" | "normal" | "comfortable";
|
||||
borderStyle: "none" | "light" | "heavy";
|
||||
alternateRowColors?: boolean;
|
||||
highlightTotals?: boolean; // 총합계 강조
|
||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||
mergeCells?: boolean; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
||||
export interface PivotExportConfig {
|
||||
excel?: boolean;
|
||||
pdf?: boolean;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// ==================== 메인 Props ====================
|
||||
|
||||
export interface PivotGridProps {
|
||||
// 기본 설정
|
||||
id?: string;
|
||||
title?: string;
|
||||
|
||||
// 데이터 소스
|
||||
dataSource?: PivotDataSourceConfig;
|
||||
|
||||
// 필드 설정
|
||||
fields?: PivotFieldConfig[];
|
||||
|
||||
// 표시 설정
|
||||
totals?: PivotTotalsConfig;
|
||||
style?: PivotStyleConfig;
|
||||
|
||||
// 필드 선택기
|
||||
fieldChooser?: FieldChooserConfig;
|
||||
|
||||
// 차트 연동
|
||||
chart?: PivotChartConfig;
|
||||
|
||||
// 기능 설정
|
||||
allowSortingBySummary?: boolean; // 요약값 기준 정렬
|
||||
allowFiltering?: boolean; // 필터링 허용
|
||||
allowExpandAll?: boolean; // 전체 확장/축소 허용
|
||||
wordWrapEnabled?: boolean; // 텍스트 줄바꿈
|
||||
|
||||
// 크기 설정
|
||||
height?: string | number;
|
||||
maxHeight?: string;
|
||||
|
||||
// 상태 저장
|
||||
stateStoring?: {
|
||||
enabled: boolean;
|
||||
storageKey?: string; // localStorage 키
|
||||
};
|
||||
|
||||
// 내보내기
|
||||
exportConfig?: PivotExportConfig;
|
||||
|
||||
// 데이터 (외부 주입용)
|
||||
data?: any[];
|
||||
|
||||
// 이벤트
|
||||
onCellClick?: (cellData: PivotCellData) => void;
|
||||
onCellDoubleClick?: (cellData: PivotCellData) => void;
|
||||
onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void;
|
||||
onExpandChange?: (expandedPaths: string[][]) => void;
|
||||
onDataChange?: (data: any[]) => void;
|
||||
}
|
||||
|
||||
// ==================== 결과 데이터 구조 ====================
|
||||
|
||||
// 셀 데이터
|
||||
export interface PivotCellData {
|
||||
value: any; // 셀 값
|
||||
rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"])
|
||||
columnPath: string[]; // 열 경로 (예: ["2024", "Q1"])
|
||||
field?: string; // 데이터 필드명
|
||||
aggregationType?: AggregationType;
|
||||
isTotal?: boolean; // 총합계 여부
|
||||
isGrandTotal?: boolean; // 대총합 여부
|
||||
}
|
||||
|
||||
// 헤더 노드 (트리 구조)
|
||||
export interface PivotHeaderNode {
|
||||
value: any; // 원본 값
|
||||
caption: string; // 표시 텍스트
|
||||
level: number; // 깊이
|
||||
children?: PivotHeaderNode[]; // 자식 노드
|
||||
isExpanded: boolean; // 확장 상태
|
||||
path: string[]; // 경로 (드릴다운용)
|
||||
subtotal?: PivotCellValue[]; // 소계
|
||||
span?: number; // colspan/rowspan
|
||||
}
|
||||
|
||||
// 셀 값
|
||||
export interface PivotCellValue {
|
||||
field: string; // 데이터 필드
|
||||
value: number | null; // 집계 값
|
||||
formattedValue: string; // 포맷된 값
|
||||
}
|
||||
|
||||
// 피벗 결과 데이터 구조
|
||||
export interface PivotResult {
|
||||
// 행 헤더 트리
|
||||
rowHeaders: PivotHeaderNode[];
|
||||
|
||||
// 열 헤더 트리
|
||||
columnHeaders: PivotHeaderNode[];
|
||||
|
||||
// 데이터 매트릭스 (rowPath + columnPath → values)
|
||||
dataMatrix: Map<string, PivotCellValue[]>;
|
||||
|
||||
// 플랫 행 목록 (렌더링용)
|
||||
flatRows: PivotFlatRow[];
|
||||
|
||||
// 플랫 열 목록 (렌더링용)
|
||||
flatColumns: PivotFlatColumn[];
|
||||
|
||||
// 총합계
|
||||
grandTotals: {
|
||||
row: Map<string, PivotCellValue[]>; // 행별 총합
|
||||
column: Map<string, PivotCellValue[]>; // 열별 총합
|
||||
grand: PivotCellValue[]; // 대총합
|
||||
};
|
||||
}
|
||||
|
||||
// 플랫 행 (렌더링용)
|
||||
export interface PivotFlatRow {
|
||||
path: string[];
|
||||
level: number;
|
||||
caption: string;
|
||||
isExpanded: boolean;
|
||||
hasChildren: boolean;
|
||||
isTotal?: boolean;
|
||||
}
|
||||
|
||||
// 플랫 열 (렌더링용)
|
||||
export interface PivotFlatColumn {
|
||||
path: string[];
|
||||
level: number;
|
||||
caption: string;
|
||||
span: number;
|
||||
isTotal?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
export interface PivotGridState {
|
||||
expandedRowPaths: string[][]; // 확장된 행 경로들
|
||||
expandedColumnPaths: string[][]; // 확장된 열 경로들
|
||||
sortConfig: {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
} | null;
|
||||
filterConfig: Record<string, any[]>; // 필드별 필터값
|
||||
}
|
||||
|
||||
// ==================== 컴포넌트 Config (화면관리용) ====================
|
||||
|
||||
export interface PivotGridComponentConfig {
|
||||
// 데이터 소스
|
||||
dataSource?: PivotDataSourceConfig;
|
||||
|
||||
// 필드 설정
|
||||
fields?: PivotFieldConfig[];
|
||||
|
||||
// 표시 설정
|
||||
totals?: PivotTotalsConfig;
|
||||
style?: PivotStyleConfig;
|
||||
|
||||
// 필드 선택기
|
||||
fieldChooser?: FieldChooserConfig;
|
||||
|
||||
// 차트 연동
|
||||
chart?: PivotChartConfig;
|
||||
|
||||
// 기능 설정
|
||||
allowSortingBySummary?: boolean;
|
||||
allowFiltering?: boolean;
|
||||
allowExpandAll?: boolean;
|
||||
wordWrapEnabled?: boolean;
|
||||
|
||||
// 크기 설정
|
||||
height?: string | number;
|
||||
maxHeight?: string;
|
||||
|
||||
// 내보내기
|
||||
exportConfig?: PivotExportConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 집계 함수 유틸리티
|
||||
* 다양한 집계 연산을 수행합니다.
|
||||
*/
|
||||
|
||||
import { getFormatRules } from "@/lib/formatting";
|
||||
|
||||
import { AggregationType, PivotFieldFormat } from "../types";
|
||||
|
||||
// ==================== 집계 함수 ====================
|
||||
|
||||
/**
|
||||
* 합계 계산
|
||||
*/
|
||||
export function sum(values: number[]): number {
|
||||
return values.reduce((acc, val) => acc + (val || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개수 계산
|
||||
*/
|
||||
export function count(values: any[]): number {
|
||||
return values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균 계산
|
||||
*/
|
||||
export function avg(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return sum(values) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최소값 계산
|
||||
*/
|
||||
export function min(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return Math.min(...values.filter((v) => v !== null && v !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대값 계산
|
||||
*/
|
||||
export function max(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return Math.max(...values.filter((v) => v !== null && v !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유값 개수 계산
|
||||
*/
|
||||
export function countDistinct(values: any[]): number {
|
||||
return new Set(values.filter((v) => v !== null && v !== undefined)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 타입에 따른 집계 수행
|
||||
*/
|
||||
export function aggregate(
|
||||
values: any[],
|
||||
type: AggregationType = "sum"
|
||||
): number {
|
||||
const numericValues = values
|
||||
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
|
||||
.filter((v) => !isNaN(v));
|
||||
|
||||
switch (type) {
|
||||
case "sum":
|
||||
return sum(numericValues);
|
||||
case "count":
|
||||
return count(values);
|
||||
case "avg":
|
||||
return avg(numericValues);
|
||||
case "min":
|
||||
return min(numericValues);
|
||||
case "max":
|
||||
return max(numericValues);
|
||||
case "countDistinct":
|
||||
return countDistinct(values);
|
||||
default:
|
||||
return sum(numericValues);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 포맷 함수 ====================
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number | null | undefined,
|
||||
format?: PivotFieldFormat
|
||||
): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
const {
|
||||
type = "number",
|
||||
precision = 0,
|
||||
thousandSeparator = true,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
} = format || {};
|
||||
|
||||
let formatted: string;
|
||||
|
||||
const locale = getFormatRules().number.locale;
|
||||
|
||||
switch (type) {
|
||||
case "currency":
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "percent":
|
||||
formatted = (value * 100).toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "number":
|
||||
default:
|
||||
if (thousandSeparator) {
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
} else {
|
||||
formatted = value.toFixed(precision);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return `${prefix}${formatted}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
export function formatDate(
|
||||
value: Date | string | null | undefined,
|
||||
format: string = getFormatRules().date.display
|
||||
): string {
|
||||
if (!value) return "-";
|
||||
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const quarter = Math.ceil((date.getMonth() + 1) / 3);
|
||||
|
||||
return format
|
||||
.replace("YYYY", String(year))
|
||||
.replace("MM", month)
|
||||
.replace("DD", day)
|
||||
.replace("Q", `Q${quarter}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 타입 라벨 반환
|
||||
*/
|
||||
export function getAggregationLabel(type: AggregationType): string {
|
||||
const labels: Record<AggregationType, string> = {
|
||||
sum: "합계",
|
||||
count: "개수",
|
||||
avg: "평균",
|
||||
min: "최소",
|
||||
max: "최대",
|
||||
countDistinct: "고유값",
|
||||
};
|
||||
return labels[type] || "합계";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
/**
|
||||
* 조건부 서식 유틸리티
|
||||
* 셀 값에 따른 스타일 계산
|
||||
*/
|
||||
|
||||
import { ConditionalFormatRule } from "../types";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface CellFormatStyle {
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
fontWeight?: string;
|
||||
dataBarWidth?: number; // 0-100%
|
||||
dataBarColor?: string;
|
||||
icon?: string; // 이모지 또는 아이콘 이름
|
||||
}
|
||||
|
||||
// ==================== 색상 유틸리티 ====================
|
||||
|
||||
/**
|
||||
* HEX 색상을 RGB로 변환
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RGB를 HEX로 변환
|
||||
*/
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = Math.round(x).toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 색상 사이의 보간
|
||||
*/
|
||||
function interpolateColor(
|
||||
color1: string,
|
||||
color2: string,
|
||||
factor: number
|
||||
): string {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return color1;
|
||||
|
||||
const r = rgb1.r + (rgb2.r - rgb1.r) * factor;
|
||||
const g = rgb1.g + (rgb2.g - rgb1.g) * factor;
|
||||
const b = rgb1.b + (rgb2.b - rgb1.b) * factor;
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// ==================== 조건부 서식 계산 ====================
|
||||
|
||||
/**
|
||||
* Color Scale 스타일 계산
|
||||
*/
|
||||
function applyColorScale(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.colorScale) return {};
|
||||
|
||||
const { minColor, midColor, maxColor } = rule.colorScale;
|
||||
const range = maxValue - minValue;
|
||||
|
||||
if (range === 0) {
|
||||
return { backgroundColor: minColor };
|
||||
}
|
||||
|
||||
const normalizedValue = (value - minValue) / range;
|
||||
|
||||
let backgroundColor: string;
|
||||
|
||||
if (midColor) {
|
||||
// 3색 그라데이션
|
||||
if (normalizedValue <= 0.5) {
|
||||
backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2);
|
||||
} else {
|
||||
backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2);
|
||||
}
|
||||
} else {
|
||||
// 2색 그라데이션
|
||||
backgroundColor = interpolateColor(minColor, maxColor, normalizedValue);
|
||||
}
|
||||
|
||||
// 배경색에 따른 텍스트 색상 결정
|
||||
const rgb = hexToRgb(backgroundColor);
|
||||
const textColor =
|
||||
rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
|
||||
return { backgroundColor, textColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Bar 스타일 계산
|
||||
*/
|
||||
function applyDataBar(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.dataBar) return {};
|
||||
|
||||
const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar;
|
||||
|
||||
const min = ruleMin ?? minValue;
|
||||
const max = ruleMax ?? maxValue;
|
||||
const range = max - min;
|
||||
|
||||
if (range === 0) {
|
||||
return { dataBarWidth: 100, dataBarColor: color };
|
||||
}
|
||||
|
||||
const width = Math.max(0, Math.min(100, ((value - min) / range) * 100));
|
||||
|
||||
return {
|
||||
dataBarWidth: width,
|
||||
dataBarColor: color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon Set 스타일 계산
|
||||
*/
|
||||
function applyIconSet(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.iconSet) return {};
|
||||
|
||||
const { type, thresholds, reverse } = rule.iconSet;
|
||||
const range = maxValue - minValue;
|
||||
const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100;
|
||||
|
||||
// 아이콘 정의
|
||||
const iconSets: Record<string, string[]> = {
|
||||
arrows: ["↓", "→", "↑"],
|
||||
traffic: ["🔴", "🟡", "🟢"],
|
||||
rating: ["⭐", "⭐⭐", "⭐⭐⭐"],
|
||||
flags: ["🚩", "🏳️", "🏁"],
|
||||
};
|
||||
|
||||
const icons = iconSets[type] || iconSets.arrows;
|
||||
const sortedIcons = reverse ? [...icons].reverse() : icons;
|
||||
|
||||
// 임계값에 따른 아이콘 선택
|
||||
let iconIndex = 0;
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
if (percentage >= thresholds[i]) {
|
||||
iconIndex = i + 1;
|
||||
}
|
||||
}
|
||||
iconIndex = Math.min(iconIndex, sortedIcons.length - 1);
|
||||
|
||||
return {
|
||||
icon: sortedIcons[iconIndex],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell Value 조건 스타일 계산
|
||||
*/
|
||||
function applyCellValue(
|
||||
value: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.cellValue) return {};
|
||||
|
||||
const { operator, value1, value2, backgroundColor, textColor, bold } =
|
||||
rule.cellValue;
|
||||
|
||||
let matches = false;
|
||||
|
||||
switch (operator) {
|
||||
case ">":
|
||||
matches = value > value1;
|
||||
break;
|
||||
case ">=":
|
||||
matches = value >= value1;
|
||||
break;
|
||||
case "<":
|
||||
matches = value < value1;
|
||||
break;
|
||||
case "<=":
|
||||
matches = value <= value1;
|
||||
break;
|
||||
case "=":
|
||||
matches = value === value1;
|
||||
break;
|
||||
case "!=":
|
||||
matches = value !== value1;
|
||||
break;
|
||||
case "between":
|
||||
matches = value2 !== undefined && value >= value1 && value <= value2;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!matches) return {};
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
textColor,
|
||||
fontWeight: bold ? "bold" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 조건부 서식 적용
|
||||
*/
|
||||
export function getConditionalStyle(
|
||||
value: number | null | undefined,
|
||||
field: string,
|
||||
rules: ConditionalFormatRule[],
|
||||
allValues: number[] // 해당 필드의 모든 값 (min/max 계산용)
|
||||
): CellFormatStyle {
|
||||
if (value === null || value === undefined || isNaN(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!rules || rules.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// min/max 계산
|
||||
const numericValues = allValues.filter((v) => !isNaN(v));
|
||||
const minValue = Math.min(...numericValues);
|
||||
const maxValue = Math.max(...numericValues);
|
||||
|
||||
let resultStyle: CellFormatStyle = {};
|
||||
|
||||
// 해당 필드에 적용되는 규칙 필터링 및 적용
|
||||
for (const rule of rules) {
|
||||
// 필드 필터 확인
|
||||
if (rule.field && rule.field !== field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ruleStyle: CellFormatStyle = {};
|
||||
|
||||
switch (rule.type) {
|
||||
case "colorScale":
|
||||
ruleStyle = applyColorScale(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "dataBar":
|
||||
ruleStyle = applyDataBar(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "iconSet":
|
||||
ruleStyle = applyIconSet(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "cellValue":
|
||||
ruleStyle = applyCellValue(value, rule);
|
||||
break;
|
||||
}
|
||||
|
||||
// 스타일 병합 (나중 규칙이 우선)
|
||||
resultStyle = { ...resultStyle, ...ruleStyle };
|
||||
}
|
||||
|
||||
return resultStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 서식 스타일을 React 스타일 객체로 변환
|
||||
*/
|
||||
export function formatStyleToReact(
|
||||
style: CellFormatStyle
|
||||
): React.CSSProperties {
|
||||
const result: React.CSSProperties = {};
|
||||
|
||||
if (style.backgroundColor) {
|
||||
result.backgroundColor = style.backgroundColor;
|
||||
}
|
||||
if (style.textColor) {
|
||||
result.color = style.textColor;
|
||||
}
|
||||
if (style.fontWeight) {
|
||||
result.fontWeight = style.fontWeight as any;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default getConditionalStyle;
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Excel 내보내기 유틸리티
|
||||
* 피벗 테이블 데이터를 Excel 파일로 내보내기
|
||||
* xlsx 라이브러리 사용 (브라우저 호환)
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
PivotResult,
|
||||
PivotFieldConfig,
|
||||
PivotTotalsConfig,
|
||||
} from "../types";
|
||||
import { pathToKey } from "./pivotEngine";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface ExportOptions {
|
||||
fileName?: string;
|
||||
sheetName?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
includeHeaders?: boolean;
|
||||
includeTotals?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터를 Excel로 내보내기
|
||||
*/
|
||||
export async function exportPivotToExcel(
|
||||
pivotResult: PivotResult,
|
||||
fields: PivotFieldConfig[],
|
||||
totals: PivotTotalsConfig,
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
fileName = "pivot_export",
|
||||
sheetName = "Pivot",
|
||||
title,
|
||||
includeHeaders = true,
|
||||
includeTotals = true,
|
||||
} = options;
|
||||
|
||||
// 필드 분류
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
// 데이터 배열 생성
|
||||
const data: any[][] = [];
|
||||
|
||||
// 제목 추가
|
||||
if (title) {
|
||||
data.push([title]);
|
||||
data.push([]); // 빈 행
|
||||
}
|
||||
|
||||
// 헤더 행
|
||||
if (includeHeaders) {
|
||||
const headerRow: any[] = [
|
||||
rowFields.map((f) => f.caption).join(" / ") || "항목",
|
||||
];
|
||||
|
||||
// 열 헤더
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
headerRow.push(col.caption || "(전체)");
|
||||
}
|
||||
|
||||
// 총계 헤더
|
||||
if (totals?.showRowGrandTotals && includeTotals) {
|
||||
headerRow.push("총계");
|
||||
}
|
||||
|
||||
data.push(headerRow);
|
||||
}
|
||||
|
||||
// 데이터 행
|
||||
for (const row of pivotResult.flatRows) {
|
||||
const excelRow: any[] = [];
|
||||
|
||||
// 행 헤더 (들여쓰기 포함)
|
||||
const indent = " ".repeat(row.level);
|
||||
excelRow.push(indent + row.caption);
|
||||
|
||||
// 데이터 셀
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = pivotResult.dataMatrix.get(cellKey);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
excelRow.push(values[0].value);
|
||||
} else {
|
||||
excelRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// 행 총계
|
||||
if (totals?.showRowGrandTotals && includeTotals) {
|
||||
const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path));
|
||||
if (rowTotal && rowTotal.length > 0) {
|
||||
excelRow.push(rowTotal[0].value);
|
||||
} else {
|
||||
excelRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
data.push(excelRow);
|
||||
}
|
||||
|
||||
// 열 총계 행
|
||||
if (totals?.showColumnGrandTotals && includeTotals) {
|
||||
const totalRow: any[] = ["총계"];
|
||||
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path));
|
||||
if (colTotal && colTotal.length > 0) {
|
||||
totalRow.push(colTotal[0].value);
|
||||
} else {
|
||||
totalRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// 대총합
|
||||
if (totals?.showRowGrandTotals) {
|
||||
const grandTotal = pivotResult.grandTotals.grand;
|
||||
if (grandTotal && grandTotal.length > 0) {
|
||||
totalRow.push(grandTotal[0].value);
|
||||
} else {
|
||||
totalRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
data.push(totalRow);
|
||||
}
|
||||
|
||||
// 워크시트 생성
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||
|
||||
// 컬럼 너비 설정
|
||||
const colWidths: XLSX.ColInfo[] = [];
|
||||
const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
for (let i = 0; i < maxCols; i++) {
|
||||
colWidths.push({ wch: i === 0 ? 25 : 15 });
|
||||
}
|
||||
worksheet["!cols"] = colWidths;
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill Down 데이터를 Excel로 내보내기
|
||||
*/
|
||||
export async function exportDrillDownToExcel(
|
||||
data: any[],
|
||||
columns: { field: string; caption: string }[],
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
fileName = "drilldown_export",
|
||||
sheetName = "Data",
|
||||
title,
|
||||
} = options;
|
||||
|
||||
// 데이터 배열 생성
|
||||
const sheetData: any[][] = [];
|
||||
|
||||
// 제목
|
||||
if (title) {
|
||||
sheetData.push([title]);
|
||||
sheetData.push([]); // 빈 행
|
||||
}
|
||||
|
||||
// 헤더
|
||||
const headerRow = columns.map((col) => col.caption);
|
||||
sheetData.push(headerRow);
|
||||
|
||||
// 데이터
|
||||
for (const row of data) {
|
||||
const dataRow = columns.map((col) => row[col.field] ?? "");
|
||||
sheetData.push(dataRow);
|
||||
}
|
||||
|
||||
// 워크시트 생성
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(sheetData);
|
||||
|
||||
// 컬럼 너비 설정
|
||||
const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 }));
|
||||
worksheet["!cols"] = colWidths;
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./aggregation";
|
||||
export * from "./pivotEngine";
|
||||
export * from "./exportExcel";
|
||||
export * from "./conditionalFormat";
|
||||
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
/**
|
||||
* PivotGrid 데이터 처리 엔진
|
||||
* 원시 데이터를 피벗 구조로 변환합니다.
|
||||
*/
|
||||
|
||||
import {
|
||||
PivotFieldConfig,
|
||||
PivotResult,
|
||||
PivotHeaderNode,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
PivotCellValue,
|
||||
DateGroupInterval,
|
||||
AggregationType,
|
||||
SummaryDisplayMode,
|
||||
} from "../types";
|
||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||
|
||||
// ==================== 헬퍼 함수 ====================
|
||||
|
||||
/**
|
||||
* 필드 값 추출 (날짜 그룹핑 포함)
|
||||
*/
|
||||
function getFieldValue(
|
||||
row: Record<string, any>,
|
||||
field: PivotFieldConfig
|
||||
): string {
|
||||
const rawValue = row[field.field];
|
||||
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return "(빈 값)";
|
||||
}
|
||||
|
||||
// 날짜 그룹핑 처리
|
||||
if (field.groupInterval && field.dataType === "date") {
|
||||
const date = new Date(rawValue);
|
||||
if (isNaN(date.getTime())) return String(rawValue);
|
||||
|
||||
switch (field.groupInterval) {
|
||||
case "year":
|
||||
return String(date.getFullYear());
|
||||
case "quarter":
|
||||
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
|
||||
case "month":
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
case "week":
|
||||
const weekNum = getWeekNumber(date);
|
||||
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
||||
case "day":
|
||||
return formatDate(date);
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차 계산
|
||||
*/
|
||||
function getWeekNumber(date: Date): number {
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
);
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로를 키로 변환
|
||||
*/
|
||||
export function pathToKey(path: string[]): string {
|
||||
return path.join("||");
|
||||
}
|
||||
|
||||
/**
|
||||
* 키를 경로로 변환
|
||||
*/
|
||||
export function keyToPath(key: string): string[] {
|
||||
return key.split("||");
|
||||
}
|
||||
|
||||
// ==================== 헤더 생성 ====================
|
||||
|
||||
/**
|
||||
* 계층적 헤더 노드 생성
|
||||
*/
|
||||
function buildHeaderTree(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedPaths: Set<string>
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
// 첫 번째 필드로 그룹화
|
||||
const firstField = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, firstField);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
// 정렬
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (firstField.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
// 노드 생성
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: 0,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
// 자식 노드 생성 (확장된 경우만)
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
1
|
||||
);
|
||||
// span 계산
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 노드 재귀 생성
|
||||
*/
|
||||
function buildChildNodes(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
parentPath: string[],
|
||||
expandedPaths: Set<string>,
|
||||
level: number
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
const field = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, field);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (field.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [...parentPath, key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: level,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
level + 1
|
||||
);
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* span 계산 (colspan/rowspan)
|
||||
*/
|
||||
function calculateSpan(children?: PivotHeaderNode[]): number {
|
||||
if (!children || children.length === 0) return 1;
|
||||
return children.reduce((sum, child) => sum + (child.span ?? 1), 0);
|
||||
}
|
||||
|
||||
// ==================== 플랫 구조 변환 ====================
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 행으로 변환
|
||||
*/
|
||||
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
|
||||
const result: PivotFlatRow[] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
result.push({
|
||||
path: node.path,
|
||||
level: node.level,
|
||||
caption: node.caption,
|
||||
isExpanded: node.isExpanded,
|
||||
hasChildren: !!(node.children && node.children.length > 0),
|
||||
});
|
||||
|
||||
if (node.isExpanded && node.children) {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 열로 변환 (각 레벨별)
|
||||
*/
|
||||
function flattenColumns(
|
||||
nodes: PivotHeaderNode[],
|
||||
maxLevel: number
|
||||
): PivotFlatColumn[][] {
|
||||
const levels: PivotFlatColumn[][] = Array.from(
|
||||
{ length: maxLevel + 1 },
|
||||
() => []
|
||||
);
|
||||
|
||||
function traverse(node: PivotHeaderNode, currentLevel: number) {
|
||||
levels[currentLevel].push({
|
||||
path: node.path,
|
||||
level: currentLevel,
|
||||
caption: node.caption,
|
||||
span: node.span ?? 1,
|
||||
});
|
||||
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, currentLevel + 1);
|
||||
}
|
||||
} else if (currentLevel < maxLevel) {
|
||||
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
|
||||
for (let i = currentLevel + 1; i <= maxLevel; i++) {
|
||||
levels[i].push({
|
||||
path: node.path,
|
||||
level: i,
|
||||
caption: "",
|
||||
span: node.span ?? 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 헤더의 최대 깊이 계산
|
||||
*/
|
||||
function getMaxColumnLevel(
|
||||
nodes: PivotHeaderNode[],
|
||||
totalFields: number
|
||||
): number {
|
||||
let maxLevel = 0;
|
||||
|
||||
function traverse(node: PivotHeaderNode, level: number) {
|
||||
maxLevel = Math.max(maxLevel, level);
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return Math.min(maxLevel, totalFields - 1);
|
||||
}
|
||||
|
||||
// ==================== 데이터 매트릭스 생성 ====================
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스 생성
|
||||
*/
|
||||
function buildDataMatrix(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
const matrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 각 셀에 대해 해당하는 데이터 집계
|
||||
for (const row of flatRows) {
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
|
||||
// 해당 행/열 경로에 맞는 데이터 필터링
|
||||
const filteredData = data.filter((record) => {
|
||||
// 행 조건 확인
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
|
||||
// 열 조건 확인
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 데이터 필드별 집계
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(
|
||||
values,
|
||||
dataField.summaryType || "sum"
|
||||
);
|
||||
const formattedValue = formatNumber(
|
||||
aggregatedValue,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
matrix.set(cellKey, cellValues);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 leaf 노드 경로 추출
|
||||
*/
|
||||
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
|
||||
const leaves: string[][] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
if (!node.isExpanded || !node.children || node.children.length === 0) {
|
||||
leaves.push(node.path);
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
// 열 필드가 없을 경우 빈 경로 추가
|
||||
if (leaves.length === 0) {
|
||||
leaves.push([]);
|
||||
}
|
||||
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// ==================== Summary Display Mode 적용 ====================
|
||||
|
||||
/**
|
||||
* Summary Display Mode에 따른 값 변환
|
||||
*/
|
||||
function applyDisplayMode(
|
||||
value: number,
|
||||
displayMode: SummaryDisplayMode | undefined,
|
||||
rowTotal: number,
|
||||
columnTotal: number,
|
||||
grandTotal: number,
|
||||
prevValue: number | null,
|
||||
runningTotal: number,
|
||||
format?: PivotFieldConfig["format"]
|
||||
): { value: number; formattedValue: string } {
|
||||
if (!displayMode || displayMode === "absoluteValue") {
|
||||
return {
|
||||
value,
|
||||
formattedValue: formatNumber(value, format),
|
||||
};
|
||||
}
|
||||
|
||||
let resultValue: number;
|
||||
let formatOverride: PivotFieldConfig["format"] | undefined;
|
||||
|
||||
switch (displayMode) {
|
||||
case "percentOfRowTotal":
|
||||
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfColumnTotal":
|
||||
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfGrandTotal":
|
||||
resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfRowGrandTotal":
|
||||
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfColumnGrandTotal":
|
||||
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "runningTotalByRow":
|
||||
case "runningTotalByColumn":
|
||||
resultValue = runningTotal;
|
||||
break;
|
||||
|
||||
case "differenceFromPrevious":
|
||||
resultValue = prevValue === null ? 0 : value - prevValue;
|
||||
break;
|
||||
|
||||
case "percentDifferenceFromPrevious":
|
||||
resultValue = prevValue === null || prevValue === 0
|
||||
? 0
|
||||
: ((value - prevValue) / Math.abs(prevValue)) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
default:
|
||||
resultValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
value: resultValue,
|
||||
formattedValue: formatNumber(resultValue, formatOverride || format),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스에 Summary Display Mode 적용
|
||||
*/
|
||||
function applyDisplayModeToMatrix(
|
||||
matrix: Map<string, PivotCellValue[]>,
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][],
|
||||
rowTotals: Map<string, PivotCellValue[]>,
|
||||
columnTotals: Map<string, PivotCellValue[]>,
|
||||
grandTotals: PivotCellValue[]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
// displayMode가 있는 데이터 필드가 있는지 확인
|
||||
const hasDisplayMode = dataFields.some(
|
||||
(df) => df.summaryDisplayMode || df.showValuesAs
|
||||
);
|
||||
if (!hasDisplayMode) return matrix;
|
||||
|
||||
const newMatrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 누계를 위한 추적 (행별, 열별)
|
||||
const rowRunningTotals: Map<string, number[]> = new Map(); // fieldIndex -> 누계
|
||||
const colRunningTotals: Map<string, Map<number, number>> = new Map(); // colKey -> fieldIndex -> 누계
|
||||
|
||||
// 행 순서대로 처리
|
||||
for (const row of flatRows) {
|
||||
// 이전 열 값 추적 (차이 계산용)
|
||||
const prevColValues: (number | null)[] = dataFields.map(() => null);
|
||||
|
||||
for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) {
|
||||
const colPath = flatColumnLeaves[colIdx];
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
const values = matrix.get(cellKey);
|
||||
|
||||
if (!values) {
|
||||
newMatrix.set(cellKey, []);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowKey = pathToKey(row.path);
|
||||
const colKey = pathToKey(colPath);
|
||||
|
||||
// 총합 가져오기
|
||||
const rowTotal = rowTotals.get(rowKey);
|
||||
const colTotal = columnTotals.get(colKey);
|
||||
|
||||
const newValues: PivotCellValue[] = values.map((val, fieldIdx) => {
|
||||
const dataField = dataFields[fieldIdx];
|
||||
const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs;
|
||||
|
||||
if (!displayMode || displayMode === "absoluteValue") {
|
||||
prevColValues[fieldIdx] = val.value;
|
||||
return val;
|
||||
}
|
||||
|
||||
// 누계 계산
|
||||
// 행 방향 누계
|
||||
if (!rowRunningTotals.has(rowKey)) {
|
||||
rowRunningTotals.set(rowKey, dataFields.map(() => 0));
|
||||
}
|
||||
const rowRunning = rowRunningTotals.get(rowKey)!;
|
||||
rowRunning[fieldIdx] += val.value || 0;
|
||||
|
||||
// 열 방향 누계
|
||||
if (!colRunningTotals.has(colKey)) {
|
||||
colRunningTotals.set(colKey, new Map());
|
||||
}
|
||||
const colRunning = colRunningTotals.get(colKey)!;
|
||||
if (!colRunning.has(fieldIdx)) {
|
||||
colRunning.set(fieldIdx, 0);
|
||||
}
|
||||
colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0));
|
||||
|
||||
const result = applyDisplayMode(
|
||||
val.value || 0,
|
||||
displayMode,
|
||||
rowTotal?.[fieldIdx]?.value || 0,
|
||||
colTotal?.[fieldIdx]?.value || 0,
|
||||
grandTotals[fieldIdx]?.value || 0,
|
||||
prevColValues[fieldIdx],
|
||||
displayMode === "runningTotalByRow"
|
||||
? rowRunning[fieldIdx]
|
||||
: colRunning.get(fieldIdx) || 0,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
prevColValues[fieldIdx] = val.value;
|
||||
|
||||
return {
|
||||
field: val.field,
|
||||
value: result.value,
|
||||
formattedValue: result.formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
newMatrix.set(cellKey, newValues);
|
||||
}
|
||||
}
|
||||
|
||||
return newMatrix;
|
||||
}
|
||||
|
||||
// ==================== 총합계 계산 ====================
|
||||
|
||||
/**
|
||||
* 총합계 계산
|
||||
*/
|
||||
function calculateGrandTotals(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): {
|
||||
row: Map<string, PivotCellValue[]>;
|
||||
column: Map<string, PivotCellValue[]>;
|
||||
grand: PivotCellValue[];
|
||||
} {
|
||||
const rowTotals = new Map<string, PivotCellValue[]>();
|
||||
const columnTotals = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 행별 총합 (각 행의 모든 열 합계)
|
||||
for (const row of flatRows) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
rowTotals.set(pathToKey(row.path), cellValues);
|
||||
}
|
||||
|
||||
// 열별 총합 (각 열의 모든 행 합계)
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
columnTotals.set(pathToKey(colPath), cellValues);
|
||||
}
|
||||
|
||||
// 대총합
|
||||
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = data.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
row: rowTotals,
|
||||
column: columnTotals,
|
||||
grand: grandValues,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터 처리
|
||||
*/
|
||||
export function processPivotData(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedRowPaths: string[][] = [],
|
||||
expandedColumnPaths: string[][] = []
|
||||
): PivotResult {
|
||||
// 영역별 필드 분리
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const columnFields = fields
|
||||
.filter((f) => f.area === "column" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const dataFields = fields
|
||||
.filter((f) => f.area === "data" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const filterFields = fields.filter(
|
||||
(f) => f.area === "filter" && f.visible !== false
|
||||
);
|
||||
|
||||
// 필터 적용
|
||||
let filteredData = data;
|
||||
for (const filterField of filterFields) {
|
||||
if (filterField.filterValues && filterField.filterValues.length > 0) {
|
||||
filteredData = filteredData.filter((row) => {
|
||||
const value = getFieldValue(row, filterField);
|
||||
if (filterField.filterType === "exclude") {
|
||||
return !filterField.filterValues!.includes(value);
|
||||
}
|
||||
return filterField.filterValues!.includes(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 확장 경로 Set 변환
|
||||
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
|
||||
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
|
||||
|
||||
// 기본 확장: 첫 번째 레벨 모두 확장
|
||||
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
|
||||
const firstField = rowFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedRowSet.add(val));
|
||||
}
|
||||
|
||||
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
|
||||
const firstField = columnFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedColSet.add(val));
|
||||
}
|
||||
|
||||
// 헤더 트리 생성
|
||||
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
|
||||
const columnHeaders = buildHeaderTree(
|
||||
filteredData,
|
||||
columnFields,
|
||||
expandedColSet
|
||||
);
|
||||
|
||||
// 플랫 구조 변환
|
||||
const flatRows = flattenRows(rowHeaders);
|
||||
const flatColumnLeaves = getColumnLeaves(columnHeaders);
|
||||
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
|
||||
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||
|
||||
// 데이터 매트릭스 생성
|
||||
let dataMatrix = buildDataMatrix(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// 총합계 계산
|
||||
const grandTotals = calculateGrandTotals(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// Summary Display Mode 적용
|
||||
dataMatrix = applyDisplayModeToMatrix(
|
||||
dataMatrix,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves,
|
||||
grandTotals.row,
|
||||
grandTotals.column,
|
||||
grandTotals.grand
|
||||
);
|
||||
|
||||
return {
|
||||
rowHeaders,
|
||||
columnHeaders,
|
||||
dataMatrix,
|
||||
flatRows,
|
||||
flatColumns: flatColumnLeaves.map((path, idx) => ({
|
||||
path,
|
||||
level: path.length - 1,
|
||||
caption: path[path.length - 1] || "",
|
||||
span: 1,
|
||||
})),
|
||||
grandTotals,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
# v2-table-grouped (그룹화 테이블)
|
||||
|
||||
## 개요
|
||||
|
||||
데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공하는 테이블 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **그룹화**: 지정된 컬럼 기준으로 데이터를 그룹핑
|
||||
- **접기/펼치기**: 그룹 헤더 클릭으로 하위 항목 토글
|
||||
- **그룹 요약**: 그룹별 개수, 합계, 평균, 최대/최소값 표시
|
||||
- **체크박스 선택**: 그룹 단위 또는 개별 항목 선택
|
||||
- **전체 펼치기/접기**: 모든 그룹 일괄 토글 버튼
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```tsx
|
||||
import { TableGroupedComponent } from "./TableGroupedComponent";
|
||||
|
||||
<TableGroupedComponent
|
||||
config={{
|
||||
selectedTable: "production_plan_mng",
|
||||
groupConfig: {
|
||||
groupByColumn: "item_code",
|
||||
groupLabelFormat: "{item_name} ({item_code})",
|
||||
defaultExpanded: true,
|
||||
summary: {
|
||||
showCount: true,
|
||||
sumColumns: ["quantity"],
|
||||
},
|
||||
},
|
||||
columns: [
|
||||
{ columnName: "plan_no", displayName: "계획번호", visible: true },
|
||||
{ columnName: "plan_date", displayName: "계획일", visible: true },
|
||||
{ columnName: "quantity", displayName: "수량", visible: true, format: "number" },
|
||||
],
|
||||
showCheckbox: true,
|
||||
checkboxMode: "multi",
|
||||
showExpandAllButton: true,
|
||||
}}
|
||||
onSelectionChange={(event) => console.log("선택:", event.selectedItems)}
|
||||
onRowClick={(event) => console.log("행 클릭:", event.row)}
|
||||
/>
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### 기본 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `selectedTable` | string | - | 데이터 테이블명 |
|
||||
| `useCustomTable` | boolean | false | 커스텀 테이블 사용 여부 |
|
||||
| `customTableName` | string | - | 커스텀 테이블명 |
|
||||
| `columns` | ColumnConfig[] | [] | 표시할 컬럼 설정 |
|
||||
|
||||
### 그룹화 설정 (groupConfig)
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `groupByColumn` | string | - | 그룹화 기준 컬럼 (필수) |
|
||||
| `groupLabelFormat` | string | "{value}" | 그룹 라벨 형식 |
|
||||
| `defaultExpanded` | boolean | true | 초기 펼침 상태 |
|
||||
| `sortDirection` | "asc" \| "desc" | "asc" | 그룹 정렬 방향 |
|
||||
| `summary.showCount` | boolean | true | 개수 표시 여부 |
|
||||
| `summary.sumColumns` | string[] | [] | 합계 컬럼 목록 |
|
||||
| `summary.avgColumns` | string[] | [] | 평균 컬럼 목록 |
|
||||
|
||||
### 표시 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `showCheckbox` | boolean | false | 체크박스 표시 |
|
||||
| `checkboxMode` | "single" \| "multi" | "multi" | 선택 모드 |
|
||||
| `showExpandAllButton` | boolean | true | 전체 펼치기/접기 버튼 |
|
||||
| `groupHeaderStyle` | "default" \| "compact" \| "card" | "default" | 그룹 헤더 스타일 |
|
||||
| `rowClickable` | boolean | true | 행 클릭 가능 여부 |
|
||||
| `maxHeight` | number | 600 | 최대 높이 (px) |
|
||||
| `emptyMessage` | string | "데이터가 없습니다." | 빈 데이터 메시지 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
### onSelectionChange
|
||||
|
||||
선택 상태가 변경될 때 호출됩니다.
|
||||
|
||||
```typescript
|
||||
interface SelectionChangeEvent {
|
||||
selectedGroups: string[]; // 선택된 그룹 키 목록
|
||||
selectedItems: any[]; // 선택된 아이템 전체
|
||||
isAllSelected: boolean; // 전체 선택 여부
|
||||
}
|
||||
```
|
||||
|
||||
### onGroupToggle
|
||||
|
||||
그룹 펼치기/접기 시 호출됩니다.
|
||||
|
||||
```typescript
|
||||
interface GroupToggleEvent {
|
||||
groupKey: string; // 그룹 키
|
||||
expanded: boolean; // 펼침 상태
|
||||
}
|
||||
```
|
||||
|
||||
### onRowClick
|
||||
|
||||
행 클릭 시 호출됩니다.
|
||||
|
||||
```typescript
|
||||
interface RowClickEvent {
|
||||
row: any; // 클릭된 행 데이터
|
||||
groupKey: string; // 그룹 키
|
||||
indexInGroup: number; // 그룹 내 인덱스
|
||||
}
|
||||
```
|
||||
|
||||
## 그룹 라벨 형식
|
||||
|
||||
`groupLabelFormat`에서 사용 가능한 플레이스홀더:
|
||||
|
||||
- `{value}`: 그룹화 컬럼의 값
|
||||
- `{컬럼명}`: 해당 컬럼의 값
|
||||
|
||||
**예시:**
|
||||
```
|
||||
groupLabelFormat: "{item_name} ({item_code}) - {category}"
|
||||
// 결과: "제품A (P001) - 완제품"
|
||||
```
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
v2-table-grouped/
|
||||
├── index.ts # Definition
|
||||
├── types.ts # 타입 정의
|
||||
├── config.ts # 기본 설정값
|
||||
├── TableGroupedComponent.tsx # 메인 컴포넌트
|
||||
├── TableGroupedConfigPanel.tsx # 설정 패널
|
||||
├── TableGroupedRenderer.tsx # 레지스트리 등록
|
||||
├── components/
|
||||
│ └── GroupHeader.tsx # 그룹 헤더
|
||||
├── hooks/
|
||||
│ └── useGroupedData.ts # 그룹화 로직 훅
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## v2-table-list와의 차이점
|
||||
|
||||
| 항목 | v2-table-list | v2-table-grouped |
|
||||
|------|---------------|------------------|
|
||||
| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) |
|
||||
| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 |
|
||||
| 선택 | 개별 행 | 그룹 단위 / 개별 단위 |
|
||||
| 요약 | 전체 합계 (선택) | 그룹별 요약 |
|
||||
| 용도 | 일반 데이터 목록 | 카테고리별 분류 데이터 |
|
||||
|
||||
## 관련 컴포넌트
|
||||
|
||||
- `v2-table-list`: 기본 테이블 (그룹화 없음)
|
||||
- `v2-pivot-grid`: 피벗 테이블 (다차원 집계)
|
||||
- `v2-split-panel-layout`: 마스터-디테일 레이아웃
|
||||
@@ -1,537 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { Loader2, FoldVertical, UnfoldVertical } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TableGroupedComponentProps } from "./types";
|
||||
import { useGroupedData } from "./hooks/useGroupedData";
|
||||
import { GroupHeader } from "./components/GroupHeader";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import type { DataProvidable, DataReceivable, DataReceivableComponentType } from "@/types/data-transfer";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core/events";
|
||||
|
||||
/**
|
||||
* v2-table-grouped 메인 컴포넌트
|
||||
*
|
||||
* 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다.
|
||||
*/
|
||||
export function TableGroupedComponent({
|
||||
config,
|
||||
isDesignMode = false,
|
||||
formData,
|
||||
onSelectionChange,
|
||||
onGroupToggle,
|
||||
onRowClick,
|
||||
externalData,
|
||||
isLoading: externalLoading,
|
||||
error: externalError,
|
||||
componentId,
|
||||
}: TableGroupedComponentProps) {
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// TableOptions Context (검색필터 연동)
|
||||
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||
|
||||
// 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||||
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||||
|
||||
// 필터 및 그룹 설정 상태 (검색필터 연동용)
|
||||
const [filters, setFilters] = useState<any[]>([]);
|
||||
const [grouping, setGrouping] = useState<string[]>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<any[]>([]);
|
||||
|
||||
// 그룹화 데이터 훅 (검색 필터 전달)
|
||||
const {
|
||||
groups,
|
||||
isLoading: hookLoading,
|
||||
error: hookError,
|
||||
toggleGroup,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
toggleItemSelection,
|
||||
toggleGroupSelection,
|
||||
toggleAllSelection,
|
||||
selectedItems,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
refresh,
|
||||
rawData,
|
||||
totalCount,
|
||||
groupCount,
|
||||
} = useGroupedData(config, externalData, linkedFilterValues);
|
||||
|
||||
const isLoading = externalLoading ?? hookLoading;
|
||||
const error = externalError ?? hookError;
|
||||
|
||||
// 필터링된 데이터 (훅에서 이미 필터 적용됨)
|
||||
const filteredData = rawData;
|
||||
|
||||
// 연결된 필터 감시
|
||||
useEffect(() => {
|
||||
const linkedFilters = config.linkedFilters;
|
||||
|
||||
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
||||
const checkLinkedFilters = () => {
|
||||
const newFilterValues: Record<string, any> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
linkedFilters.forEach((filter) => {
|
||||
if (filter.enabled === false) return;
|
||||
|
||||
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
||||
if (sourceProvider) {
|
||||
const selectedData = sourceProvider.getSelectedData();
|
||||
if (selectedData && selectedData.length > 0) {
|
||||
const sourceField = filter.sourceField || "value";
|
||||
const value = selectedData[0][sourceField];
|
||||
|
||||
if (value !== linkedFilterValues[filter.targetColumn]) {
|
||||
newFilterValues[filter.targetColumn] = value;
|
||||
hasChanges = true;
|
||||
} else {
|
||||
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setLinkedFilterValues(newFilterValues);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 확인
|
||||
checkLinkedFilters();
|
||||
|
||||
// 주기적 확인 (100ms 간격)
|
||||
const intervalId = setInterval(checkLinkedFilters, 100);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [screenContext, config.linkedFilters, linkedFilterValues]);
|
||||
|
||||
// DataProvidable 인터페이스 구현
|
||||
const dataProvider: DataProvidable = useMemo(
|
||||
() => ({
|
||||
component_id: componentId || "",
|
||||
component_type: "table-grouped",
|
||||
|
||||
getSelectedData: () => {
|
||||
return selectedItems;
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
return filteredData;
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
toggleAllSelection();
|
||||
},
|
||||
}),
|
||||
[componentId, selectedItems, filteredData, toggleAllSelection]
|
||||
);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver: DataReceivable = useMemo(
|
||||
() => ({
|
||||
component_id: componentId || "",
|
||||
component_type: "table" as DataReceivableComponentType,
|
||||
|
||||
receiveData: async (_receivedData: any[], _config: any) => {
|
||||
// 현재는 외부 데이터 수신 시 새로고침만 수행
|
||||
refresh();
|
||||
},
|
||||
|
||||
getData: () => {
|
||||
return filteredData;
|
||||
},
|
||||
}),
|
||||
[componentId, refresh, filteredData]
|
||||
);
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && componentId) {
|
||||
screenContext.registerDataProvider(componentId, dataProvider);
|
||||
screenContext.registerDataReceiver(componentId, dataReceiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(componentId);
|
||||
screenContext.unregisterDataReceiver(componentId);
|
||||
};
|
||||
}
|
||||
}, [screenContext, componentId, dataProvider, dataReceiver]);
|
||||
|
||||
// 테이블 ID (검색필터 연동용)
|
||||
const tableId = componentId || `table-grouped-${config.selectedTable || "default"}`;
|
||||
|
||||
// TableOptionsContext에 테이블 등록 (검색필터가 테이블을 찾을 수 있도록)
|
||||
useEffect(() => {
|
||||
if (isDesignMode || !config.selectedTable) return;
|
||||
|
||||
const columnsToRegister = config.columns || [];
|
||||
|
||||
// 고유 값 조회 함수
|
||||
const getColumnUniqueValues = async (columnName: string) => {
|
||||
const uniqueValues = new Set<string>();
|
||||
rawData.forEach((row) => {
|
||||
const value = row[columnName];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
uniqueValues.add(String(value));
|
||||
}
|
||||
});
|
||||
return Array.from(uniqueValues)
|
||||
.map((value) => ({ value, label: value }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
const registration = {
|
||||
table_id: tableId,
|
||||
label: config.selectedTable,
|
||||
table_name: config.selectedTable,
|
||||
data_count: totalCount,
|
||||
columns: columnsToRegister.map((col) => ({
|
||||
column_name: col.columnName,
|
||||
column_label: col.displayName || col.columnName,
|
||||
input_type: "text",
|
||||
visible: col.visible !== false,
|
||||
width: col.width || 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
})),
|
||||
onFilterChange: setFilters,
|
||||
onGroupChange: setGrouping,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getColumnUniqueValues,
|
||||
};
|
||||
|
||||
registerTable(registration);
|
||||
|
||||
return () => {
|
||||
unregisterTable(tableId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableId, config.selectedTable, config.columns, totalCount, rawData, registerTable, isDesignMode]);
|
||||
|
||||
// 데이터 건수 변경 시 업데이트
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && config.selectedTable) {
|
||||
updateTableDataCount(tableId, totalCount);
|
||||
}
|
||||
}, [tableId, totalCount, updateTableDataCount, config.selectedTable, isDesignMode]);
|
||||
|
||||
// 필터 변경 시 검색 조건 적용
|
||||
useEffect(() => {
|
||||
if (filters.length > 0) {
|
||||
const newFilterValues: Record<string, any> = {};
|
||||
filters.forEach((filter: any) => {
|
||||
if (filter.value) {
|
||||
newFilterValues[filter.columnName] = filter.value;
|
||||
}
|
||||
});
|
||||
setLinkedFilterValues((prev) => ({ ...prev, ...newFilterValues }));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// 컬럼 설정
|
||||
const columns = config.columns || [];
|
||||
const visibleColumns = columns.filter((col) => col.visible !== false);
|
||||
|
||||
// 체크박스 컬럼 포함 시 총 컬럼 수
|
||||
const totalColumnCount = visibleColumns.length + (config.showCheckbox ? 1 : 0);
|
||||
|
||||
// 아이템 ID 추출 함수
|
||||
const getItemId = useCallback(
|
||||
(item: any): string => {
|
||||
if (item.id !== undefined) return String(item.id);
|
||||
const firstCol = columns[0]?.columnName;
|
||||
if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]);
|
||||
return JSON.stringify(item);
|
||||
},
|
||||
[columns]
|
||||
);
|
||||
|
||||
// 선택 변경 시 콜백 및 이벤트 발송
|
||||
useEffect(() => {
|
||||
// 기존 콜백 호출
|
||||
if (onSelectionChange && selectedItems.length >= 0) {
|
||||
onSelectionChange({
|
||||
selectedGroups: groups
|
||||
.filter((g) => g.selected)
|
||||
.map((g) => g.groupKey),
|
||||
selectedItems,
|
||||
isAllSelected,
|
||||
});
|
||||
}
|
||||
|
||||
// TABLE_SELECTION_CHANGE 이벤트 발송 (선택 데이터 변경 시 다른 컴포넌트에 알림)
|
||||
v2EventBus.emit(V2_EVENTS.TABLE_SELECTION_CHANGE, {
|
||||
tableName: config.selectedTable || "",
|
||||
selectedRows: selectedItems,
|
||||
selectedRowIds: selectedItems.map((item: any) => item.id).filter(Boolean),
|
||||
source: componentId || tableId,
|
||||
});
|
||||
|
||||
console.log("[TableGroupedComponent] 선택 변경 이벤트 발송:", {
|
||||
componentId: componentId || tableId,
|
||||
tableName: config.selectedTable,
|
||||
selectedCount: selectedItems.length,
|
||||
});
|
||||
}, [selectedItems, groups, isAllSelected, onSelectionChange, componentId, tableId, config.selectedTable]);
|
||||
|
||||
// 그룹 토글 핸들러
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupKey: string) => {
|
||||
toggleGroup(groupKey);
|
||||
if (onGroupToggle) {
|
||||
const group = groups.find((g) => g.groupKey === groupKey);
|
||||
onGroupToggle({
|
||||
groupKey,
|
||||
expanded: !group?.expanded,
|
||||
});
|
||||
}
|
||||
},
|
||||
[toggleGroup, onGroupToggle, groups]
|
||||
);
|
||||
|
||||
// 행 클릭 핸들러
|
||||
const handleRowClick = useCallback(
|
||||
(row: any, groupKey: string, indexInGroup: number) => {
|
||||
if (!config.rowClickable) return;
|
||||
if (onRowClick) {
|
||||
onRowClick({ row, groupKey, indexInGroup });
|
||||
}
|
||||
},
|
||||
[config.rowClickable, onRowClick]
|
||||
);
|
||||
|
||||
// refreshTable 이벤트 구독
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("refreshTable", handleRefresh);
|
||||
return () => {
|
||||
window.removeEventListener("refreshTable", handleRefresh);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// 디자인 모드 렌더링
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FoldVertical className="h-4 w-4" />
|
||||
<span>그룹화 테이블</span>
|
||||
{config.groupConfig?.groupByColumn && (
|
||||
<span className="text-xs">
|
||||
(그룹: {config.groupConfig.groupByColumn})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
테이블: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{config.emptyMessage || "데이터가 없습니다."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="v2-table-grouped flex flex-col"
|
||||
style={{
|
||||
height: config.height,
|
||||
maxHeight: config.maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* 툴바 */}
|
||||
{config.showExpandAllButton && (
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={expandAll}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<UnfoldVertical className="mr-1 h-3 w-3" />
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={collapseAll}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<FoldVertical className="mr-1 h-3 w-3" />
|
||||
전체 접기
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{groupCount}개 그룹 | 총 {totalCount}건
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* 테이블 헤더 */}
|
||||
<thead className="sticky top-0 z-10 bg-muted">
|
||||
<tr>
|
||||
{/* 전체 선택 체크박스 */}
|
||||
{config.showCheckbox && (
|
||||
<th className="w-10 whitespace-nowrap border-b px-3 py-2 text-left">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={toggleAllSelection}
|
||||
className={cn(isIndeterminate && "data-[state=checked]:bg-muted")}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{/* 컬럼 헤더 */}
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col.columnName}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b px-3 py-2 font-medium text-muted-foreground",
|
||||
col.align === "center" && "text-center",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
<tbody>
|
||||
{groups.map((group) => (
|
||||
<React.Fragment key={group.groupKey}>
|
||||
{/* 그룹 헤더 */}
|
||||
<GroupHeader
|
||||
group={group}
|
||||
config={config}
|
||||
onToggle={() => handleGroupToggle(group.groupKey)}
|
||||
onSelectToggle={
|
||||
config.showCheckbox
|
||||
? () => toggleGroupSelection(group.groupKey)
|
||||
: undefined
|
||||
}
|
||||
style={config.groupHeaderStyle}
|
||||
columnCount={totalColumnCount}
|
||||
/>
|
||||
|
||||
{/* 그룹 아이템 (펼쳐진 경우만) */}
|
||||
{group.expanded &&
|
||||
group.items.map((item, idx) => {
|
||||
const itemId = getItemId(item);
|
||||
const isSelected = group.selectedItemIds?.includes(itemId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={itemId}
|
||||
className={cn(
|
||||
"border-b transition-colors",
|
||||
config.rowClickable && "cursor-pointer hover:bg-muted/50",
|
||||
isSelected && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => handleRowClick(item, group.groupKey, idx)}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
{config.showCheckbox && (
|
||||
<td
|
||||
className="px-3 py-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() =>
|
||||
toggleItemSelection(group.groupKey, itemId)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* 데이터 컬럼 */}
|
||||
{visibleColumns.map((col) => {
|
||||
const value = item[col.columnName];
|
||||
let displayValue: React.ReactNode = value;
|
||||
|
||||
// 포맷 적용
|
||||
if (col.format === "number" && typeof value === "number") {
|
||||
displayValue = value.toLocaleString();
|
||||
} else if (col.format === "currency" && typeof value === "number") {
|
||||
displayValue = `₩${value.toLocaleString()}`;
|
||||
} else if (col.format === "date" && value) {
|
||||
displayValue = new Date(value).toLocaleDateString("ko-KR");
|
||||
} else if (col.format === "boolean") {
|
||||
displayValue = value ? "예" : "아니오";
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.columnName}
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
col.align === "center" && "text-center",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
>
|
||||
{displayValue ?? "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableGroupedComponent;
|
||||
@@ -1,718 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { TableGroupedConfig, LinkedFilterConfig } from "./types";
|
||||
import { ColumnConfig } from "../v2-table-list/types";
|
||||
import {
|
||||
groupHeaderStyleOptions,
|
||||
checkboxModeOptions,
|
||||
sortDirectionOptions,
|
||||
} from "./config";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
|
||||
interface TableGroupedConfigPanelProps {
|
||||
config: TableGroupedConfig;
|
||||
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2-table-grouped 설정 패널
|
||||
*/
|
||||
// 테이블 정보 타입
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function TableGroupedConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: TableGroupedConfigPanelProps) {
|
||||
// 테이블 목록 (라벨명 포함)
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
if (tableList && Array.isArray(tableList)) {
|
||||
setTables(
|
||||
tableList.map((t: any) => ({
|
||||
tableName: t.table_name,
|
||||
displayName: t.display_name || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("테이블 목록 로드 실패:", err);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const tableName = config.useCustomTable
|
||||
? config.customTableName
|
||||
: config.selectedTable;
|
||||
|
||||
if (!tableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
if (columns && Array.isArray(columns)) {
|
||||
const cols: ColumnConfig[] = columns.map(
|
||||
(col: any, idx: number) => ({
|
||||
columnName: col.column_name,
|
||||
displayName: col.display_name || col.column_name,
|
||||
visible: true,
|
||||
sortable: true,
|
||||
searchable: false,
|
||||
align: "left" as const,
|
||||
order: idx,
|
||||
})
|
||||
);
|
||||
setTableColumns(cols);
|
||||
|
||||
// 컬럼 설정이 없으면 자동 설정
|
||||
if (!config.columns || config.columns.length === 0) {
|
||||
onChange({ ...config, columns: cols });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("컬럼 로드 실패:", err);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.selectedTable, config.customTableName, config.useCustomTable]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
};
|
||||
|
||||
// 그룹 설정 업데이트 헬퍼
|
||||
const updateGroupConfig = (
|
||||
updates: Partial<TableGroupedConfig["groupConfig"]>
|
||||
) => {
|
||||
onChange({
|
||||
...config,
|
||||
groupConfig: { ...config.groupConfig, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 가시성 토글
|
||||
const toggleColumnVisibility = (columnName: string) => {
|
||||
const updatedColumns = (config.columns || []).map((col) =>
|
||||
col.columnName === columnName ? { ...col, visible: !col.visible } : col
|
||||
);
|
||||
updateConfig({ columns: updatedColumns });
|
||||
};
|
||||
|
||||
// 합계 컬럼 토글
|
||||
const toggleSumColumn = (columnName: string) => {
|
||||
const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
|
||||
const newSumCols = currentSumCols.includes(columnName)
|
||||
? currentSumCols.filter((c) => c !== columnName)
|
||||
: [...currentSumCols, columnName];
|
||||
|
||||
updateGroupConfig({
|
||||
summary: {
|
||||
...config.groupConfig?.summary,
|
||||
sumColumns: newSumCols,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 연결 필터 추가
|
||||
const addLinkedFilter = () => {
|
||||
const newFilter: LinkedFilterConfig = {
|
||||
sourceComponentId: "",
|
||||
sourceField: "value",
|
||||
targetColumn: "",
|
||||
enabled: true,
|
||||
};
|
||||
updateConfig({
|
||||
linkedFilters: [...(config.linkedFilters || []), newFilter],
|
||||
});
|
||||
};
|
||||
|
||||
// 연결 필터 제거
|
||||
const removeLinkedFilter = (index: number) => {
|
||||
const filters = [...(config.linkedFilters || [])];
|
||||
filters.splice(index, 1);
|
||||
updateConfig({ linkedFilters: filters });
|
||||
};
|
||||
|
||||
// 연결 필터 업데이트
|
||||
const updateLinkedFilter = (
|
||||
index: number,
|
||||
updates: Partial<LinkedFilterConfig>
|
||||
) => {
|
||||
const filters = [...(config.linkedFilters || [])];
|
||||
filters[index] = { ...filters[index], ...updates };
|
||||
updateConfig({ linkedFilters: filters });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Accordion type="multiple" defaultValue={["table", "group", "display"]}>
|
||||
{/* 테이블 설정 */}
|
||||
<AccordionItem value="table">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
테이블 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
{/* 커스텀 테이블 사용 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">커스텀 테이블 사용</Label>
|
||||
<Switch
|
||||
checked={config.useCustomTable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ useCustomTable: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{config.useCustomTable ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">커스텀 테이블명</Label>
|
||||
<Input
|
||||
value={config.customTableName || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig({ customTableName: e.target.value })
|
||||
}
|
||||
placeholder="테이블명 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
{loadingTables ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</>
|
||||
) : config.selectedTable ? (
|
||||
<span className="truncate">
|
||||
{tables.find((t) => t.tableName === config.selectedTable)
|
||||
?.displayName || config.selectedTable}
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({config.selectedTable})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
"테이블 검색..."
|
||||
)}
|
||||
<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
|
||||
filter={(value, search) => {
|
||||
// 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0
|
||||
const lowerSearch = search.toLowerCase();
|
||||
if (value.toLowerCase().includes(lowerSearch)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="테이블명 또는 라벨 검색..."
|
||||
className="text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ selectedTable: table.tableName });
|
||||
setTableSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.selectedTable === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{table.tableName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 그룹화 설정 */}
|
||||
<AccordionItem value="group">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
그룹화 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
{/* 그룹화 기준 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그룹화 기준 컬럼 *</Label>
|
||||
<Select
|
||||
value={config.groupConfig?.groupByColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateGroupConfig({ groupByColumn: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 그룹 라벨 형식 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그룹 라벨 형식</Label>
|
||||
<Input
|
||||
value={config.groupConfig?.groupLabelFormat || "{value}"}
|
||||
onChange={(e) =>
|
||||
updateGroupConfig({ groupLabelFormat: e.target.value })
|
||||
}
|
||||
placeholder="{value} ({컬럼명})"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 펼침 상태 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">기본 펼침 상태</Label>
|
||||
<Switch
|
||||
checked={config.groupConfig?.defaultExpanded ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateGroupConfig({ defaultExpanded: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 그룹 정렬 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그룹 정렬</Label>
|
||||
<Select
|
||||
value={config.groupConfig?.sortDirection || "asc"}
|
||||
onValueChange={(value: "asc" | "desc") =>
|
||||
updateGroupConfig({ sortDirection: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortDirectionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 개수 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">개수 표시</Label>
|
||||
<Switch
|
||||
checked={config.groupConfig?.summary?.showCount ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateGroupConfig({
|
||||
summary: {
|
||||
...config.groupConfig?.summary,
|
||||
showCount: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 합계 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">합계 표시 컬럼</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
||||
{tableColumns.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id={`sum-${col.columnName}`}
|
||||
checked={
|
||||
config.groupConfig?.summary?.sumColumns?.includes(
|
||||
col.columnName
|
||||
) ?? false
|
||||
}
|
||||
onCheckedChange={() => toggleSumColumn(col.columnName)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`sum-${col.columnName}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 표시 설정 */}
|
||||
<AccordionItem value="display">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
표시 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
{/* 체크박스 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">체크박스 표시</Label>
|
||||
<Switch
|
||||
checked={config.showCheckbox}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showCheckbox: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 모드 */}
|
||||
{config.showCheckbox && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">선택 모드</Label>
|
||||
<Select
|
||||
value={config.checkboxMode || "multi"}
|
||||
onValueChange={(value: "single" | "multi") =>
|
||||
updateConfig({ checkboxMode: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{checkboxModeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 헤더 스타일 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그룹 헤더 스타일</Label>
|
||||
<Select
|
||||
value={config.groupHeaderStyle || "default"}
|
||||
onValueChange={(value: "default" | "compact" | "card") =>
|
||||
updateConfig({ groupHeaderStyle: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupHeaderStyleOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전체 펼치기/접기 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">펼치기/접기 버튼 표시</Label>
|
||||
<Switch
|
||||
checked={config.showExpandAllButton ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showExpandAllButton: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 행 클릭 가능 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">행 클릭 가능</Label>
|
||||
<Switch
|
||||
checked={config.rowClickable ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ rowClickable: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 최대 높이 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최대 높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.maxHeight || 600}
|
||||
onChange={(e) =>
|
||||
updateConfig({ maxHeight: parseInt(e.target.value) || 600 })
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 빈 데이터 메시지 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">빈 데이터 메시지</Label>
|
||||
<Input
|
||||
value={config.emptyMessage || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig({ emptyMessage: e.target.value })
|
||||
}
|
||||
placeholder="데이터가 없습니다."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 컬럼 설정 */}
|
||||
<AccordionItem value="columns">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
컬럼 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pt-2">
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
|
||||
{(config.columns || tableColumns).map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id={`col-${col.columnName}`}
|
||||
checked={col.visible !== false}
|
||||
onCheckedChange={() =>
|
||||
toggleColumnVisibility(col.columnName)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`col-${col.columnName}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 연동 설정 */}
|
||||
<AccordionItem value="linked">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
연동 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">연결 필터</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLinkedFilter}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(config.linkedFilters || []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
연결된 필터가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.linkedFilters || []).map((filter, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="space-y-2 rounded border p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">
|
||||
필터 #{idx + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={filter.enabled !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLinkedFilter(idx, { enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLinkedFilter(idx)}
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">소스 컴포넌트 ID</Label>
|
||||
<Input
|
||||
value={filter.sourceComponentId}
|
||||
onChange={(e) =>
|
||||
updateLinkedFilter(idx, {
|
||||
sourceComponentId: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: search-filter-1"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">소스 필드</Label>
|
||||
<Input
|
||||
value={filter.sourceField || "value"}
|
||||
onChange={(e) =>
|
||||
updateLinkedFilter(idx, {
|
||||
sourceField: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="value"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 컬럼</Label>
|
||||
<Select
|
||||
value={filter.targetColumn}
|
||||
onValueChange={(value) =>
|
||||
updateLinkedFilter(idx, { targetColumn: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableGroupedConfigPanel;
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2TableGroupedDefinition } from "./index";
|
||||
import { TableGroupedComponent } from "./TableGroupedComponent";
|
||||
|
||||
/**
|
||||
* TableGrouped 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TableGroupedRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2TableGroupedDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return (
|
||||
<TableGroupedComponent
|
||||
{...this.props}
|
||||
config={this.props.component?.componentConfig || {}}
|
||||
isDesignMode={this.props.isDesignMode}
|
||||
formData={this.props.formData}
|
||||
componentId={this.props.component?.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 설정 변경 핸들러
|
||||
protected handleConfigChange = (config: any) => {
|
||||
console.log("📥 TableGroupedRenderer에서 설정 변경 받음:", config);
|
||||
|
||||
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
|
||||
if (this.props.onConfigChange) {
|
||||
this.props.onConfigChange(config);
|
||||
}
|
||||
|
||||
this.updateComponent({ config });
|
||||
};
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TableGroupedRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
TableGroupedRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("❌ TableGrouped 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronRight, Minus } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GroupState, TableGroupedConfig } from "../types";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
/** 그룹 상태 */
|
||||
group: GroupState;
|
||||
/** 설정 */
|
||||
config: TableGroupedConfig;
|
||||
/** 그룹 토글 핸들러 */
|
||||
onToggle: () => void;
|
||||
/** 그룹 선택 토글 핸들러 */
|
||||
onSelectToggle?: () => void;
|
||||
/** 그룹 헤더 스타일 */
|
||||
style?: "default" | "compact" | "card";
|
||||
/** 컬럼 개수 (colspan용) */
|
||||
columnCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 헤더 컴포넌트
|
||||
* 그룹 펼치기/접기, 체크박스, 요약 정보 표시
|
||||
*/
|
||||
export function GroupHeader({
|
||||
group,
|
||||
config,
|
||||
onToggle,
|
||||
onSelectToggle,
|
||||
style = "default",
|
||||
columnCount = 1,
|
||||
}: GroupHeaderProps) {
|
||||
const { showCheckbox } = config;
|
||||
const { summary } = group;
|
||||
|
||||
// 일부 선택 여부
|
||||
const isIndeterminate =
|
||||
group.selectedItemIds &&
|
||||
group.selectedItemIds.length > 0 &&
|
||||
group.selectedItemIds.length < group.items.length;
|
||||
|
||||
// 요약 텍스트 생성
|
||||
const summaryText = React.useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 개수
|
||||
if (config.groupConfig?.summary?.showCount !== false) {
|
||||
parts.push(`${summary.count}건`);
|
||||
}
|
||||
|
||||
// 합계
|
||||
if (summary.sum) {
|
||||
for (const [col, value] of Object.entries(summary.sum)) {
|
||||
const displayName =
|
||||
config.columns?.find((c) => c.columnName === col)?.displayName || col;
|
||||
parts.push(`${displayName}: ${value.toLocaleString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" | ");
|
||||
}, [summary, config]);
|
||||
|
||||
// 스타일별 클래스
|
||||
const headerClasses = cn(
|
||||
"flex items-center gap-2 cursor-pointer select-none transition-colors",
|
||||
{
|
||||
// default 스타일
|
||||
"px-3 py-2 bg-muted/50 hover:bg-muted border-b": style === "default",
|
||||
// compact 스타일
|
||||
"px-2 py-1 bg-muted/30 hover:bg-muted/50 border-b text-sm":
|
||||
style === "compact",
|
||||
// card 스타일
|
||||
"px-4 py-3 bg-card border rounded-t-lg shadow-sm hover:shadow":
|
||||
style === "card",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="group-header-row">
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className="p-0"
|
||||
onClick={(e) => {
|
||||
// 체크박스 클릭 시 토글 방지
|
||||
if ((e.target as HTMLElement).closest('[data-checkbox="true"]')) {
|
||||
return;
|
||||
}
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<div className={headerClasses}>
|
||||
{/* 펼치기/접기 아이콘 */}
|
||||
<span className="flex-shrink-0 text-muted-foreground">
|
||||
{group.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 체크박스 */}
|
||||
{showCheckbox && onSelectToggle && (
|
||||
<span
|
||||
data-checkbox="true"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectToggle();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={group.selected}
|
||||
className={cn(
|
||||
"data-[state=checked]:bg-primary",
|
||||
isIndeterminate && "data-[state=checked]:bg-muted"
|
||||
)}
|
||||
/>
|
||||
{isIndeterminate && (
|
||||
<Minus className="absolute h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 그룹 라벨 */}
|
||||
<span className="font-medium text-foreground">{group.groupLabel}</span>
|
||||
|
||||
{/* 요약 정보 */}
|
||||
{summaryText && (
|
||||
<span className="ml-auto text-sm text-muted-foreground">
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeader;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { TableGroupedConfig } from "./types";
|
||||
|
||||
/**
|
||||
* v2-table-grouped 기본 설정값
|
||||
*/
|
||||
export const defaultTableGroupedConfig: Partial<TableGroupedConfig> = {
|
||||
// 그룹화 기본 설정
|
||||
groupConfig: {
|
||||
groupByColumn: "",
|
||||
groupLabelFormat: "{value}",
|
||||
defaultExpanded: true,
|
||||
sortDirection: "asc",
|
||||
summary: {
|
||||
showCount: true,
|
||||
sumColumns: [],
|
||||
},
|
||||
},
|
||||
|
||||
// 체크박스 기본 설정
|
||||
showCheckbox: false,
|
||||
checkboxMode: "multi",
|
||||
|
||||
// 페이지네이션 기본 설정
|
||||
pagination: {
|
||||
enabled: false,
|
||||
pageSize: 10,
|
||||
},
|
||||
|
||||
// UI 기본 설정
|
||||
isReadOnly: false,
|
||||
rowClickable: true,
|
||||
showExpandAllButton: true,
|
||||
groupHeaderStyle: "default",
|
||||
emptyMessage: "데이터가 없습니다.",
|
||||
|
||||
// 높이 기본 설정
|
||||
height: "auto",
|
||||
maxHeight: 600,
|
||||
};
|
||||
|
||||
/**
|
||||
* 그룹 헤더 스타일 옵션
|
||||
*/
|
||||
export const groupHeaderStyleOptions = [
|
||||
{ value: "default", label: "기본" },
|
||||
{ value: "compact", label: "컴팩트" },
|
||||
{ value: "card", label: "카드" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 체크박스 모드 옵션
|
||||
*/
|
||||
export const checkboxModeOptions = [
|
||||
{ value: "single", label: "단일 선택" },
|
||||
{ value: "multi", label: "다중 선택" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 정렬 방향 옵션
|
||||
*/
|
||||
export const sortDirectionOptions = [
|
||||
{ value: "asc", label: "오름차순" },
|
||||
{ value: "desc", label: "내림차순" },
|
||||
];
|
||||
@@ -1,411 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import {
|
||||
TableGroupedConfig,
|
||||
GroupState,
|
||||
GroupSummary,
|
||||
UseGroupedDataResult,
|
||||
} from "../types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* 그룹 요약 데이터 계산
|
||||
*/
|
||||
function calculateSummary(
|
||||
items: any[],
|
||||
config: TableGroupedConfig
|
||||
): GroupSummary {
|
||||
const summary: GroupSummary = {
|
||||
count: items.length,
|
||||
};
|
||||
|
||||
const summaryConfig = config.groupConfig?.summary;
|
||||
if (!summaryConfig) return summary;
|
||||
|
||||
// 합계 계산
|
||||
if (summaryConfig.sumColumns && summaryConfig.sumColumns.length > 0) {
|
||||
summary.sum = {};
|
||||
for (const col of summaryConfig.sumColumns) {
|
||||
summary.sum[col] = items.reduce((acc, item) => {
|
||||
const val = parseFloat(item[col]);
|
||||
return acc + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 평균 계산
|
||||
if (summaryConfig.avgColumns && summaryConfig.avgColumns.length > 0) {
|
||||
summary.avg = {};
|
||||
for (const col of summaryConfig.avgColumns) {
|
||||
const validItems = items.filter(
|
||||
(item) => item[col] !== null && item[col] !== undefined
|
||||
);
|
||||
const sum = validItems.reduce((acc, item) => {
|
||||
const val = parseFloat(item[col]);
|
||||
return acc + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
summary.avg[col] = validItems.length > 0 ? sum / validItems.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 최대값 계산
|
||||
if (summaryConfig.maxColumns && summaryConfig.maxColumns.length > 0) {
|
||||
summary.max = {};
|
||||
for (const col of summaryConfig.maxColumns) {
|
||||
const values = items
|
||||
.map((item) => parseFloat(item[col]))
|
||||
.filter((v) => !isNaN(v));
|
||||
summary.max[col] = values.length > 0 ? Math.max(...values) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 최소값 계산
|
||||
if (summaryConfig.minColumns && summaryConfig.minColumns.length > 0) {
|
||||
summary.min = {};
|
||||
for (const col of summaryConfig.minColumns) {
|
||||
const values = items
|
||||
.map((item) => parseFloat(item[col]))
|
||||
.filter((v) => !isNaN(v));
|
||||
summary.min[col] = values.length > 0 ? Math.min(...values) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 라벨 포맷팅
|
||||
*/
|
||||
function formatGroupLabel(
|
||||
groupValue: any,
|
||||
item: any,
|
||||
format?: string
|
||||
): string {
|
||||
if (!format) {
|
||||
return String(groupValue ?? "(빈 값)");
|
||||
}
|
||||
|
||||
// {value}를 그룹 값으로 치환
|
||||
let label = format.replace("{value}", String(groupValue ?? "(빈 값)"));
|
||||
|
||||
// {컬럼명} 패턴을 해당 컬럼 값으로 치환
|
||||
const columnPattern = /\{([^}]+)\}/g;
|
||||
label = label.replace(columnPattern, (match, columnName) => {
|
||||
if (columnName === "value") return String(groupValue ?? "");
|
||||
return String(item?.[columnName] ?? "");
|
||||
});
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 그룹화하는 훅
|
||||
*/
|
||||
export function useGroupedData(
|
||||
config: TableGroupedConfig,
|
||||
externalData?: any[],
|
||||
searchFilters?: Record<string, any>
|
||||
): UseGroupedDataResult {
|
||||
// 원본 데이터
|
||||
const [rawData, setRawData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 그룹 펼침 상태 관리
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
|
||||
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
|
||||
|
||||
// 선택 상태 관리
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// 테이블명 결정
|
||||
const tableName = config.useCustomTable
|
||||
? config.customTableName
|
||||
: config.selectedTable;
|
||||
|
||||
// 데이터 로드
|
||||
const fetchData = useCallback(async () => {
|
||||
if (externalData) {
|
||||
setRawData(externalData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
setRawData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 10000, // 그룹화를 위해 전체 데이터 로드
|
||||
autoFilter: true,
|
||||
search: searchFilters || {},
|
||||
}
|
||||
);
|
||||
|
||||
let responseData = response.data?.data?.data || response.data?.data || [];
|
||||
responseData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
// dataFilter 적용 (클라이언트 사이드 필터링)
|
||||
if (config.dataFilter && config.dataFilter.length > 0) {
|
||||
responseData = responseData.filter((item: any) => {
|
||||
return config.dataFilter!.every((f) => {
|
||||
const val = item[f.column];
|
||||
switch (f.operator) {
|
||||
case "eq": return val === f.value;
|
||||
case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value;
|
||||
case "gt": return Number(val) > Number(f.value);
|
||||
case "lt": return Number(val) < Number(f.value);
|
||||
case "gte": return Number(val) >= Number(f.value);
|
||||
case "lte": return Number(val) <= Number(f.value);
|
||||
case "like": return String(val ?? "").includes(String(f.value));
|
||||
case "in": return Array.isArray(f.value) ? f.value.includes(val) : false;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setRawData(responseData);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "데이터 로드 중 오류 발생");
|
||||
setRawData([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tableName, externalData, searchFilters, config.dataFilter]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 외부 데이터 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (externalData) {
|
||||
setRawData(externalData);
|
||||
}
|
||||
}, [externalData]);
|
||||
|
||||
// 그룹화된 데이터 계산
|
||||
const groups = useMemo((): GroupState[] => {
|
||||
const groupByColumn = config.groupConfig?.groupByColumn;
|
||||
if (!groupByColumn || rawData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 데이터를 그룹별로 분류
|
||||
const groupMap = new Map<string, any[]>();
|
||||
|
||||
for (const item of rawData) {
|
||||
const groupValue = item[groupByColumn];
|
||||
const groupKey = String(groupValue ?? "__null__");
|
||||
|
||||
if (!groupMap.has(groupKey)) {
|
||||
groupMap.set(groupKey, []);
|
||||
}
|
||||
groupMap.get(groupKey)!.push(item);
|
||||
}
|
||||
|
||||
// 그룹 배열 생성
|
||||
const groupArray: GroupState[] = [];
|
||||
const defaultExpanded = config.groupConfig?.defaultExpanded ?? true;
|
||||
|
||||
for (const [groupKey, items] of groupMap.entries()) {
|
||||
const firstItem = items[0];
|
||||
const groupValue =
|
||||
groupKey === "__null__" ? null : firstItem[groupByColumn];
|
||||
|
||||
// 펼침 상태 결정: 수동 조작 전에는 defaultExpanded, 수동 조작 후에는 expandedGroups 참조
|
||||
const isExpanded = isManuallyControlled
|
||||
? expandedGroups.has(groupKey)
|
||||
: defaultExpanded;
|
||||
|
||||
groupArray.push({
|
||||
groupKey,
|
||||
groupLabel: formatGroupLabel(
|
||||
groupValue,
|
||||
firstItem,
|
||||
config.groupConfig?.groupLabelFormat
|
||||
),
|
||||
expanded: isExpanded,
|
||||
items,
|
||||
summary: calculateSummary(items, config),
|
||||
selected: items.every((item) =>
|
||||
selectedItemIds.has(getItemId(item, config))
|
||||
),
|
||||
selectedItemIds: items
|
||||
.filter((item) => selectedItemIds.has(getItemId(item, config)))
|
||||
.map((item) => getItemId(item, config)),
|
||||
});
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortDirection = config.groupConfig?.sortDirection ?? "asc";
|
||||
groupArray.sort((a, b) => {
|
||||
const comparison = a.groupLabel.localeCompare(b.groupLabel, "ko");
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return groupArray;
|
||||
}, [rawData, config, expandedGroups, selectedItemIds, isManuallyControlled]);
|
||||
|
||||
// 아이템 ID 추출
|
||||
function getItemId(item: any, cfg: TableGroupedConfig): string {
|
||||
// id 또는 첫 번째 컬럼을 ID로 사용
|
||||
if (item.id !== undefined) return String(item.id);
|
||||
const firstCol = cfg.columns?.[0]?.columnName;
|
||||
if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]);
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
|
||||
// 그룹 토글
|
||||
const toggleGroup = useCallback((groupKey: string) => {
|
||||
setIsManuallyControlled(true);
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupKey)) {
|
||||
next.delete(groupKey);
|
||||
} else {
|
||||
next.add(groupKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 펼치기
|
||||
const expandAll = useCallback(() => {
|
||||
setIsManuallyControlled(true);
|
||||
setExpandedGroups(new Set(groups.map((g) => g.groupKey)));
|
||||
}, [groups]);
|
||||
|
||||
// 전체 접기
|
||||
const collapseAll = useCallback(() => {
|
||||
setIsManuallyControlled(true);
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
// 아이템 선택 토글
|
||||
const toggleItemSelection = useCallback(
|
||||
(groupKey: string, itemId: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId);
|
||||
} else {
|
||||
// 단일 선택 모드
|
||||
if (config.checkboxMode === "single") {
|
||||
next.clear();
|
||||
}
|
||||
next.add(itemId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[config.checkboxMode]
|
||||
);
|
||||
|
||||
// 그룹 전체 선택 토글
|
||||
const toggleGroupSelection = useCallback(
|
||||
(groupKey: string) => {
|
||||
const group = groups.find((g) => g.groupKey === groupKey);
|
||||
if (!group) return;
|
||||
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
const groupItemIds = group.items.map((item) => getItemId(item, config));
|
||||
const allSelected = groupItemIds.every((id) => next.has(id));
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
for (const id of groupItemIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
} else {
|
||||
// 전체 선택
|
||||
if (config.checkboxMode === "single") {
|
||||
next.clear();
|
||||
next.add(groupItemIds[0]);
|
||||
} else {
|
||||
for (const id of groupItemIds) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[groups, config]
|
||||
);
|
||||
|
||||
// 전체 선택 토글
|
||||
const toggleAllSelection = useCallback(() => {
|
||||
const allItemIds = rawData.map((item) => getItemId(item, config));
|
||||
const allSelected = allItemIds.every((id) => selectedItemIds.has(id));
|
||||
|
||||
if (allSelected) {
|
||||
setSelectedItemIds(new Set());
|
||||
} else {
|
||||
if (config.checkboxMode === "single" && allItemIds.length > 0) {
|
||||
setSelectedItemIds(new Set([allItemIds[0]]));
|
||||
} else {
|
||||
setSelectedItemIds(new Set(allItemIds));
|
||||
}
|
||||
}
|
||||
}, [rawData, config, selectedItemIds]);
|
||||
|
||||
// 선택된 아이템 목록
|
||||
const selectedItems = useMemo(() => {
|
||||
return rawData.filter((item) =>
|
||||
selectedItemIds.has(getItemId(item, config))
|
||||
);
|
||||
}, [rawData, selectedItemIds, config]);
|
||||
|
||||
// 모두 선택 여부
|
||||
const isAllSelected = useMemo(() => {
|
||||
if (rawData.length === 0) return false;
|
||||
return rawData.every((item) =>
|
||||
selectedItemIds.has(getItemId(item, config))
|
||||
);
|
||||
}, [rawData, selectedItemIds, config]);
|
||||
|
||||
// 일부 선택 여부
|
||||
const isIndeterminate = useMemo(() => {
|
||||
if (rawData.length === 0) return false;
|
||||
const selectedCount = rawData.filter((item) =>
|
||||
selectedItemIds.has(getItemId(item, config))
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < rawData.length;
|
||||
}, [rawData, selectedItemIds, config]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading,
|
||||
error,
|
||||
toggleGroup,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
toggleItemSelection,
|
||||
toggleGroupSelection,
|
||||
toggleAllSelection,
|
||||
selectedItems,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
refresh: fetchData,
|
||||
rawData,
|
||||
totalCount: rawData.length,
|
||||
groupCount: groups.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default useGroupedData;
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TableGroupedComponent } from "./TableGroupedComponent";
|
||||
import { V2TableGroupedConfigPanel } from "@/components/v2/config-panels/V2TableGroupedConfigPanel";
|
||||
import { TableGroupedConfig } from "./types";
|
||||
|
||||
/**
|
||||
* V2 그룹화 테이블 컴포넌트 Definition
|
||||
*
|
||||
* 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다.
|
||||
* v2-table-list를 기반으로 확장된 컴포넌트입니다.
|
||||
*/
|
||||
export const V2TableGroupedDefinition = createComponentDefinition({
|
||||
id: "v2-table-grouped",
|
||||
hidden: true, // Phase E: 통합 컴포넌트로 대체됨
|
||||
name: "그룹화 테이블",
|
||||
name_eng: "Grouped Table Component",
|
||||
description: "데이터를 그룹화하여 접기/펼치기 기능을 제공하는 테이블",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
web_type: "text",
|
||||
component: TableGroupedComponent,
|
||||
default_config: {
|
||||
// 테이블 설정
|
||||
selectedTable: "",
|
||||
useCustomTable: false,
|
||||
customTableName: "",
|
||||
|
||||
// 그룹화 설정
|
||||
groupConfig: {
|
||||
groupByColumn: "",
|
||||
groupLabelFormat: "{value}",
|
||||
defaultExpanded: true,
|
||||
sortDirection: "asc",
|
||||
summary: {
|
||||
showCount: true,
|
||||
sumColumns: [],
|
||||
},
|
||||
},
|
||||
|
||||
// 컬럼 설정
|
||||
columns: [],
|
||||
|
||||
// 체크박스 설정
|
||||
showCheckbox: false,
|
||||
checkboxMode: "multi",
|
||||
|
||||
// 페이지네이션 설정
|
||||
pagination: {
|
||||
enabled: false,
|
||||
pageSize: 10,
|
||||
},
|
||||
|
||||
// UI 설정
|
||||
isReadOnly: false,
|
||||
rowClickable: true,
|
||||
showExpandAllButton: true,
|
||||
groupHeaderStyle: "default",
|
||||
emptyMessage: "데이터가 없습니다.",
|
||||
height: "auto",
|
||||
maxHeight: 600,
|
||||
},
|
||||
default_size: { width: 800, height: 500 },
|
||||
config_panel: V2TableGroupedConfigPanel,
|
||||
icon: "Layers",
|
||||
tags: ["테이블", "그룹화", "접기", "펼치기", "목록"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TableGroupedConfig } from "./types";
|
||||
@@ -1,299 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import { ColumnConfig, EntityJoinInfo } from "../v2-table-list/types";
|
||||
|
||||
/**
|
||||
* 그룹 요약 설정
|
||||
*/
|
||||
export interface GroupSummaryConfig {
|
||||
/** 합계를 계산할 컬럼 목록 */
|
||||
sumColumns?: string[];
|
||||
/** 개수 표시 여부 */
|
||||
showCount?: boolean;
|
||||
/** 평균 컬럼 목록 */
|
||||
avgColumns?: string[];
|
||||
/** 최대값 컬럼 목록 */
|
||||
maxColumns?: string[];
|
||||
/** 최소값 컬럼 목록 */
|
||||
minColumns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹화 설정
|
||||
*/
|
||||
export interface GroupConfig {
|
||||
/** 그룹화 기준 컬럼 */
|
||||
groupByColumn: string;
|
||||
|
||||
/** 그룹 표시 형식 (예: "{item_name} ({item_code})") */
|
||||
groupLabelFormat?: string;
|
||||
|
||||
/** 그룹 요약 설정 */
|
||||
summary?: GroupSummaryConfig;
|
||||
|
||||
/** 초기 펼침 상태 (기본값: true) */
|
||||
defaultExpanded?: boolean;
|
||||
|
||||
/** 중첩 그룹 (다중 그룹화) - 향후 확장 */
|
||||
nestedGroup?: GroupConfig;
|
||||
|
||||
/** 그룹 정렬 방식 */
|
||||
sortDirection?: "asc" | "desc";
|
||||
|
||||
/** 그룹 정렬 컬럼 (기본: groupByColumn) */
|
||||
sortColumn?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹화 테이블 설정 (ComponentConfig 기반)
|
||||
*/
|
||||
export interface TableGroupedConfig extends ComponentConfig {
|
||||
/** 테이블명 */
|
||||
selectedTable?: string;
|
||||
|
||||
/** 커스텀 테이블 사용 여부 */
|
||||
useCustomTable?: boolean;
|
||||
|
||||
/** 커스텀 테이블명 */
|
||||
customTableName?: string;
|
||||
|
||||
/** 그룹화 설정 */
|
||||
groupConfig: GroupConfig;
|
||||
|
||||
/** 컬럼 설정 */
|
||||
columns?: ColumnConfig[];
|
||||
|
||||
/** 체크박스 표시 여부 */
|
||||
showCheckbox?: boolean;
|
||||
|
||||
/** 체크박스 모드 */
|
||||
checkboxMode?: "single" | "multi";
|
||||
|
||||
/** 페이지네이션 (그룹 단위) */
|
||||
pagination?: {
|
||||
enabled: boolean;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
/** 기본 정렬 설정 */
|
||||
defaultSort?: {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
/** 읽기 전용 */
|
||||
isReadOnly?: boolean;
|
||||
|
||||
/** 행 클릭 가능 여부 */
|
||||
rowClickable?: boolean;
|
||||
|
||||
/** 높이 설정 */
|
||||
height?: number | string;
|
||||
|
||||
/** 최대 높이 */
|
||||
maxHeight?: number | string;
|
||||
|
||||
/** 전체 펼치기/접기 버튼 표시 */
|
||||
showExpandAllButton?: boolean;
|
||||
|
||||
/** 그룹 헤더 스타일 */
|
||||
groupHeaderStyle?: "default" | "compact" | "card";
|
||||
|
||||
/** 빈 데이터 메시지 */
|
||||
emptyMessage?: string;
|
||||
|
||||
/** Entity 조인 컬럼 정보 */
|
||||
entityJoinColumns?: Array<{
|
||||
columnName: string;
|
||||
entityJoinInfo: EntityJoinInfo;
|
||||
}>;
|
||||
|
||||
/** 데이터 필터 */
|
||||
dataFilter?: {
|
||||
column: string;
|
||||
operator: "eq" | "ne" | "gt" | "lt" | "gte" | "lte" | "like" | "in";
|
||||
value: any;
|
||||
}[];
|
||||
|
||||
/** 연결된 필터 설정 (다른 컴포넌트와 연동) */
|
||||
linkedFilters?: LinkedFilterConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결된 필터 설정
|
||||
*/
|
||||
export interface LinkedFilterConfig {
|
||||
/** 소스 컴포넌트 ID */
|
||||
sourceComponentId: string;
|
||||
/** 소스 필드 */
|
||||
sourceField?: string;
|
||||
/** 대상 컬럼 */
|
||||
targetColumn: string;
|
||||
/** 활성화 여부 */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 요약 데이터
|
||||
*/
|
||||
export interface GroupSummary {
|
||||
/** 개수 */
|
||||
count: number;
|
||||
/** 합계 (컬럼별) */
|
||||
sum?: Record<string, number>;
|
||||
/** 평균 (컬럼별) */
|
||||
avg?: Record<string, number>;
|
||||
/** 최대값 (컬럼별) */
|
||||
max?: Record<string, number>;
|
||||
/** 최소값 (컬럼별) */
|
||||
min?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 상태
|
||||
*/
|
||||
export interface GroupState {
|
||||
/** 그룹 키 (groupByColumn 값) */
|
||||
groupKey: string;
|
||||
|
||||
/** 그룹 표시 라벨 */
|
||||
groupLabel: string;
|
||||
|
||||
/** 펼침 여부 */
|
||||
expanded: boolean;
|
||||
|
||||
/** 그룹 내 데이터 */
|
||||
items: any[];
|
||||
|
||||
/** 그룹 요약 데이터 */
|
||||
summary: GroupSummary;
|
||||
|
||||
/** 그룹 선택 여부 */
|
||||
selected?: boolean;
|
||||
|
||||
/** 그룹 내 선택된 아이템 ID 목록 */
|
||||
selectedItemIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 이벤트 데이터
|
||||
*/
|
||||
export interface SelectionChangeEvent {
|
||||
/** 선택된 그룹 키 목록 */
|
||||
selectedGroups: string[];
|
||||
/** 선택된 아이템 (전체) */
|
||||
selectedItems: any[];
|
||||
/** 모두 선택 여부 */
|
||||
isAllSelected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 토글 이벤트
|
||||
*/
|
||||
export interface GroupToggleEvent {
|
||||
/** 그룹 키 */
|
||||
groupKey: string;
|
||||
/** 펼침 상태 */
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 행 클릭 이벤트
|
||||
*/
|
||||
export interface RowClickEvent {
|
||||
/** 클릭된 행 데이터 */
|
||||
row: any;
|
||||
/** 그룹 키 */
|
||||
groupKey: string;
|
||||
/** 그룹 내 인덱스 */
|
||||
indexInGroup: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TableGroupedComponent Props
|
||||
*/
|
||||
export interface TableGroupedComponentProps {
|
||||
/** 컴포넌트 설정 */
|
||||
config: TableGroupedConfig;
|
||||
|
||||
/** 디자인 모드 여부 */
|
||||
isDesignMode?: boolean;
|
||||
|
||||
/** 폼 데이터 (formData 전달용) */
|
||||
formData?: Record<string, any>;
|
||||
|
||||
/** 선택 변경 이벤트 */
|
||||
onSelectionChange?: (event: SelectionChangeEvent) => void;
|
||||
|
||||
/** 그룹 토글 이벤트 */
|
||||
onGroupToggle?: (event: GroupToggleEvent) => void;
|
||||
|
||||
/** 행 클릭 이벤트 */
|
||||
onRowClick?: (event: RowClickEvent) => void;
|
||||
|
||||
/** 외부에서 주입된 데이터 (선택) */
|
||||
externalData?: any[];
|
||||
|
||||
/** 로딩 상태 (외부 제어) */
|
||||
isLoading?: boolean;
|
||||
|
||||
/** 에러 상태 (외부 제어) */
|
||||
error?: string;
|
||||
|
||||
/** 컴포넌트 ID */
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useGroupedData 훅 반환 타입
|
||||
*/
|
||||
export interface UseGroupedDataResult {
|
||||
/** 그룹화된 데이터 */
|
||||
groups: GroupState[];
|
||||
|
||||
/** 로딩 상태 */
|
||||
isLoading: boolean;
|
||||
|
||||
/** 에러 */
|
||||
error: string | null;
|
||||
|
||||
/** 그룹 펼치기/접기 토글 */
|
||||
toggleGroup: (groupKey: string) => void;
|
||||
|
||||
/** 전체 펼치기 */
|
||||
expandAll: () => void;
|
||||
|
||||
/** 전체 접기 */
|
||||
collapseAll: () => void;
|
||||
|
||||
/** 아이템 선택 토글 */
|
||||
toggleItemSelection: (groupKey: string, itemId: string) => void;
|
||||
|
||||
/** 그룹 전체 선택 토글 */
|
||||
toggleGroupSelection: (groupKey: string) => void;
|
||||
|
||||
/** 전체 선택 토글 */
|
||||
toggleAllSelection: () => void;
|
||||
|
||||
/** 선택된 아이템 목록 */
|
||||
selectedItems: any[];
|
||||
|
||||
/** 모두 선택 여부 */
|
||||
isAllSelected: boolean;
|
||||
|
||||
/** 일부 선택 여부 */
|
||||
isIndeterminate: boolean;
|
||||
|
||||
/** 데이터 새로고침 */
|
||||
refresh: () => void;
|
||||
|
||||
/** 원본 데이터 */
|
||||
rawData: any[];
|
||||
|
||||
/** 전체 데이터 개수 */
|
||||
totalCount: number;
|
||||
|
||||
/** 그룹 개수 */
|
||||
groupCount: number;
|
||||
}
|
||||
@@ -378,14 +378,6 @@ const v2CategoryManagerOverridesSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// v2-pivot-grid
|
||||
const v2PivotGridOverridesSchema = z
|
||||
.object({
|
||||
fields: z.array(z.any()).default([]),
|
||||
dataSource: z.any().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// v2-location-swap-selector
|
||||
const v2LocationSwapSelectorOverridesSchema = z
|
||||
.object({
|
||||
@@ -422,26 +414,6 @@ const v2AggregationWidgetOverridesSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// v2-card-display
|
||||
const v2CardDisplayOverridesSchema = z
|
||||
.object({
|
||||
cardsPerRow: z.number().default(3),
|
||||
cardSpacing: z.number().default(16),
|
||||
cardStyle: z
|
||||
.object({
|
||||
showTitle: z.boolean().default(true),
|
||||
showSubtitle: z.boolean().default(true),
|
||||
showDescription: z.boolean().default(true),
|
||||
showImage: z.boolean().default(false),
|
||||
showActions: z.boolean().default(true),
|
||||
})
|
||||
.default({ showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true }),
|
||||
columnMapping: z.record(z.string(), z.any()).default({}),
|
||||
dataSource: z.string().default("table"),
|
||||
staticData: z.array(z.any()).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// v2-table-search-widget
|
||||
const v2TableSearchWidgetOverridesSchema = z
|
||||
.object({
|
||||
@@ -672,10 +644,8 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
||||
"v2-rack-structure": v2RackStructureOverridesSchema,
|
||||
"v2-numbering-rule": v2NumberingRuleOverridesSchema,
|
||||
"v2-category-manager": v2CategoryManagerOverridesSchema,
|
||||
"v2-pivot-grid": v2PivotGridOverridesSchema,
|
||||
"v2-location-swap-selector": v2LocationSwapSelectorOverridesSchema,
|
||||
"v2-aggregation-widget": v2AggregationWidgetOverridesSchema,
|
||||
"v2-card-display": v2CardDisplayOverridesSchema,
|
||||
"v2-table-search-widget": v2TableSearchWidgetOverridesSchema,
|
||||
"v2-tabs-widget": v2TabsWidgetOverridesSchema,
|
||||
"v2-repeater": v2V2RepeaterOverridesSchema,
|
||||
@@ -786,9 +756,6 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||
maxDepth: 3,
|
||||
showActions: true,
|
||||
},
|
||||
"v2-pivot-grid": {
|
||||
fields: [],
|
||||
},
|
||||
"v2-location-swap-selector": {
|
||||
dataSource: { type: "static", tableName: "", valueField: "location_code", labelField: "location_name" },
|
||||
departureField: "departure",
|
||||
@@ -810,14 +777,6 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||
autoRefresh: false,
|
||||
refreshOnFormChange: true,
|
||||
},
|
||||
"v2-card-display": {
|
||||
cardsPerRow: 3,
|
||||
cardSpacing: 16,
|
||||
cardStyle: { showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true },
|
||||
columnMapping: {},
|
||||
dataSource: "table",
|
||||
staticData: [],
|
||||
},
|
||||
"v2-table-search-widget": {
|
||||
title: "테이블 검색",
|
||||
autoSelectFirstTable: true,
|
||||
|
||||
@@ -63,8 +63,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
|
||||
// ========== 레이아웃/컨테이너 ==========
|
||||
"accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"),
|
||||
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
|
||||
"v2-card-display": () => import("@/lib/registry/components/v2-card-display/CardDisplayConfigPanel"),
|
||||
"section-card": () => import("@/lib/registry/components/section-card/SectionCardConfigPanel"),
|
||||
"v2-section-card": () => import("@/lib/registry/components/v2-section-card/SectionCardConfigPanel"),
|
||||
"section-paper": () => import("@/lib/registry/components/section-paper/SectionPaperConfigPanel"),
|
||||
@@ -79,8 +77,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
// ========== 테이블/리스트 ==========
|
||||
"table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),
|
||||
"v2-table-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"),
|
||||
"pivot-grid": () => import("@/lib/registry/components/pivot-grid/PivotGridConfigPanel"),
|
||||
"v2-pivot-grid": () => import("@/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel"),
|
||||
"table-search-widget": () => import("@/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel"),
|
||||
"v2-table-search-widget": () => import("@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel"),
|
||||
"tax-invoice-list": () => import("@/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel"),
|
||||
@@ -157,9 +153,8 @@ const CONFIG_PANEL_ALIAS: Record<string, string> = {
|
||||
"text-input": "input", "number-input": "input", "date-input": "input",
|
||||
"select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input",
|
||||
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
|
||||
"v2-status-count": "stats", "v2-card-display": "stats",
|
||||
"v2-status-count": "stats",
|
||||
"v2-table-list": "table", "table-list": "table",
|
||||
"v2-table-grouped": "table", "v2-pivot-grid": "table",
|
||||
"v2-tabs-widget": "container", "v2-section-card": "container",
|
||||
"v2-section-paper": "container", "v2-repeat-container": "container",
|
||||
"section-card": "container", "section-paper": "container",
|
||||
|
||||
@@ -50,12 +50,9 @@ const LEGACY_TO_UNIFIED: Record<string, string> = {
|
||||
'v2-aggregation-widget': 'stats',
|
||||
'aggregation-widget': 'stats',
|
||||
'v2-status-count': 'stats',
|
||||
'v2-card-display': 'stats',
|
||||
'card-display': 'stats',
|
||||
'v2-table-list': 'table',
|
||||
'table-list': 'table',
|
||||
'v2-table-grouped': 'table',
|
||||
'v2-pivot-grid': 'table',
|
||||
'v2-tabs-widget': 'container',
|
||||
'v2-section-card': 'container',
|
||||
'v2-section-paper': 'container',
|
||||
|
||||
@@ -76,15 +76,6 @@ export interface RefreshTableDetail {
|
||||
component_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 디스플레이 새로고침 이벤트
|
||||
* 발행: buttonActions, InteractiveScreenViewerDynamic
|
||||
* 구독: v2-card-display
|
||||
*/
|
||||
export interface RefreshCardDisplayDetail {
|
||||
component_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 간 데이터 전달 이벤트
|
||||
* 발행: buttonActions
|
||||
@@ -156,7 +147,6 @@ export const V2_EVENTS = {
|
||||
|
||||
// UI 갱신 이벤트
|
||||
REFRESH_TABLE: "refreshTable",
|
||||
REFRESH_CARD_DISPLAY: "refreshCardDisplay",
|
||||
|
||||
// 데이터 전달 이벤트
|
||||
COMPONENT_DATA_TRANSFER: "componentDataTransfer",
|
||||
@@ -190,7 +180,6 @@ declare global {
|
||||
|
||||
// UI 갱신 이벤트
|
||||
[V2_EVENTS.REFRESH_TABLE]: CustomEvent<RefreshTableDetail>;
|
||||
[V2_EVENTS.REFRESH_CARD_DISPLAY]: CustomEvent<RefreshCardDisplayDetail>;
|
||||
|
||||
// 데이터 전달 이벤트
|
||||
[V2_EVENTS.COMPONENT_DATA_TRANSFER]: CustomEvent<ComponentDataTransferDetail>;
|
||||
|
||||
Reference in New Issue
Block a user