ERP-node/frontend/components/report/designer/modals/TableColumnDropZone.tsx

225 lines
7.6 KiB
TypeScript
Raw Normal View History

"use client";
/**
* TableColumnDropZone
*
* 1 ,
* TableColumnPalette에서 .
* .
* / .
*/
import React from "react";
import { useDrag, useDrop } from "react-dnd";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { X, Columns, GripVertical } from "lucide-react";
import { TABLE_COLUMN_DND_TYPE } from "./TableColumnPalette";
import type { ComponentConfig } from "@/types/report";
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
export const TABLE_SLOT_DND_TYPE = "table-slot";
interface TableColumnDropZoneProps {
columns: TableColumn[];
onUpdate: (idx: number, updates: Partial<TableColumn>) => void;
onDrop: (slotIndex: number, columnName: string, dataType: string) => void;
onClear: (slotIndex: number) => void;
onMove: (fromIndex: number, toIndex: number) => void;
}
interface SlotDragItem {
type: typeof TABLE_SLOT_DND_TYPE;
sourceIndex: number;
}
interface PaletteDragItem {
columnName: string;
dataType: string;
}
interface SlotProps {
col: TableColumn;
idx: number;
onUpdate: (idx: number, updates: Partial<TableColumn>) => void;
onDrop: (slotIndex: number, columnName: string, dataType: string) => void;
onClear: (slotIndex: number) => void;
onMove: (fromIndex: number, toIndex: number) => void;
}
function DropSlot({ col, idx, onUpdate, onDrop, onClear, onMove }: SlotProps) {
const isEmpty = !col.field;
const [{ isDragging }, drag, preview] = useDrag(() => ({
type: TABLE_SLOT_DND_TYPE,
item: { type: TABLE_SLOT_DND_TYPE, sourceIndex: idx } as SlotDragItem,
canDrag: !isEmpty,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}), [idx, isEmpty]);
const [{ isOver, canDrop: canDropHere }, drop] = useDrop(() => ({
accept: [TABLE_COLUMN_DND_TYPE, TABLE_SLOT_DND_TYPE],
drop: (item: PaletteDragItem | SlotDragItem, monitor) => {
const itemType = monitor.getItemType();
if (itemType === TABLE_SLOT_DND_TYPE) {
const slotItem = item as SlotDragItem;
if (slotItem.sourceIndex !== idx) {
onMove(slotItem.sourceIndex, idx);
}
} else {
const paletteItem = item as PaletteDragItem;
onDrop(idx, paletteItem.columnName, paletteItem.dataType);
}
},
canDrop: (item, monitor) => {
if (monitor.getItemType() === TABLE_SLOT_DND_TYPE) {
return (item as SlotDragItem).sourceIndex !== idx;
}
return true;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}), [idx, onDrop, onMove]);
const isActive = isOver && canDropHere;
const ref = (node: HTMLDivElement | null) => {
preview(drop(node));
};
return (
<div
ref={ref}
className={`relative rounded-lg border p-3 transition-all ${
isDragging
? "border-blue-300 bg-blue-50/60 opacity-50 shadow-inner"
: isActive
? "border-blue-400 bg-blue-50 shadow-sm ring-2 ring-blue-200"
: isEmpty
? "border-dashed border-gray-300 bg-gray-50/50"
: "border-gray-200 bg-white hover:border-blue-200 hover:bg-blue-50/20"
}`}
>
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-4 text-center">
<Columns className="mb-1 h-5 w-5 text-gray-300" />
<span className="text-xs text-gray-400">
{isActive ? "여기에 놓으세요" : "컬럼을 드래그하여 배치"}
</span>
<span className="mt-0.5 text-[10px] text-gray-300"> {idx + 1}</span>
</div>
) : (
<div className="space-y-2">
{/* 드래그 핸들 + 컬럼명 배지 + 제거 버튼 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span
ref={drag}
className="flex h-5 w-5 cursor-grab items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing"
title="드래그하여 위치 이동"
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<span className="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 font-mono text-xs font-medium text-blue-700">
<Columns className="h-3 w-3" />
{col.field}
</span>
</div>
<button
onClick={() => onClear(idx)}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-red-50 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</div>
{/* 헤더명 */}
<div>
<label className="mb-1 block text-[10px] text-gray-500"></label>
<Input
value={col.header}
onChange={(e) => onUpdate(idx, { header: e.target.value })}
className="h-7 text-xs"
/>
</div>
{/* 숫자형식 */}
<div>
<label className="mb-1 block text-[10px] text-gray-500"></label>
<Select
value={col.numberFormat || "none"}
onValueChange={(v) => onUpdate(idx, { numberFormat: v as "none" | "comma" | "currency" })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="comma">(,)</SelectItem>
<SelectItem value="currency"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
);
}
export function TableColumnDropZone({ columns, onUpdate, onDrop, onClear, onMove }: TableColumnDropZoneProps) {
const filledCount = columns.filter((c) => c.field).length;
return (
<div className="rounded-xl border border-border bg-white shadow-sm">
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5">
<span className="text-sm font-bold text-gray-800">
</span>
<span className="ml-2 text-xs font-normal text-muted-foreground">
{filledCount}/{columns.length}
</span>
{filledCount >= 2 && (
<span className="ml-2 text-[10px] text-gray-400">
( )
</span>
)}
</div>
<div className="p-3">
{columns.length === 0 ? (
<p className="py-6 text-center text-xs text-gray-400">
.
</p>
) : (
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${Math.min(columns.length, 4)}, 1fr)` }}
>
{columns.map((col, idx) => (
<DropSlot
key={idx}
col={col}
idx={idx}
onUpdate={onUpdate}
onDrop={onDrop}
onClear={onClear}
onMove={onMove}
/>
))}
</div>
)}
</div>
</div>
);
}