225 lines
7.6 KiB
TypeScript
225 lines
7.6 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|