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:
DDD1542
2026-04-29 14:38:05 +09:00
parent 3e6bce70d1
commit e44ba2953a
76 changed files with 3 additions and 27041 deletions
@@ -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;
}
-41
View File
@@ -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",
-3
View File
@@ -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',
-11
View File
@@ -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>;