ERP-node/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx

758 lines
22 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 { 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,
} 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-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,
};
// 필터 적용 여부 확인
const hasFilter = field.filterValues && field.filterValues.length > 0;
const filterCount = field.filterValues?.length || 0;
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>
{/* 필터 적용 개수 배지 */}
{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 />
</>
)}
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({
...field,
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
})
}
>
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* 필터 초기화 (필터가 적용된 경우에만 표시) */}
{hasFilter && (
<>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, filterValues: [] })}
className="text-orange-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-orange-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-orange-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-blue-500" />
</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;