578 lines
16 KiB
TypeScript
578 lines
16 KiB
TypeScript
"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-orange-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-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800",
|
|
},
|
|
row: {
|
|
title: "행",
|
|
icon: <Rows className="h-3.5 w-3.5" />,
|
|
color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-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;
|
|
|