758 lines
22 KiB
TypeScript
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;
|
|
|