390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { ChevronDown, Check } from "lucide-react";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { RepeaterColumnConfig } from "./types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface RepeaterTableProps {
|
|
columns: RepeaterColumnConfig[];
|
|
data: any[];
|
|
onDataChange: (newData: any[]) => void;
|
|
onRowChange: (index: number, newRow: any) => void;
|
|
onRowDelete: (index: number) => void;
|
|
// 동적 데이터 소스 관련
|
|
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
|
|
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
|
|
// 체크박스 선택 관련
|
|
selectedRows: Set<number>; // 선택된 행 인덱스
|
|
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
|
|
}
|
|
|
|
export function RepeaterTable({
|
|
columns,
|
|
data,
|
|
onDataChange,
|
|
onRowChange,
|
|
onRowDelete,
|
|
activeDataSources = {},
|
|
onDataSourceChange,
|
|
selectedRows,
|
|
onSelectionChange,
|
|
}: RepeaterTableProps) {
|
|
const [editingCell, setEditingCell] = useState<{
|
|
rowIndex: number;
|
|
field: string;
|
|
} | null>(null);
|
|
|
|
// 동적 데이터 소스 Popover 열림 상태
|
|
const [openPopover, setOpenPopover] = useState<string | null>(null);
|
|
|
|
// 컬럼 너비 상태 관리
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
|
const widths: Record<string, number> = {};
|
|
columns.forEach((col) => {
|
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
|
});
|
|
return widths;
|
|
});
|
|
|
|
// 기본 너비 저장 (리셋용)
|
|
const defaultWidths = React.useMemo(() => {
|
|
const widths: Record<string, number> = {};
|
|
columns.forEach((col) => {
|
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
|
});
|
|
return widths;
|
|
}, [columns]);
|
|
|
|
// 리사이즈 상태
|
|
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
|
|
|
// 리사이즈 핸들러
|
|
const handleMouseDown = (e: React.MouseEvent, field: string) => {
|
|
e.preventDefault();
|
|
setResizing({
|
|
field,
|
|
startX: e.clientX,
|
|
startWidth: columnWidths[field] || 120,
|
|
});
|
|
};
|
|
|
|
// 더블클릭으로 기본 너비로 리셋
|
|
const handleDoubleClick = (field: string) => {
|
|
setColumnWidths((prev) => ({
|
|
...prev,
|
|
[field]: defaultWidths[field] || 120,
|
|
}));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!resizing) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!resizing) return;
|
|
const diff = e.clientX - resizing.startX;
|
|
const newWidth = Math.max(60, resizing.startWidth + diff);
|
|
setColumnWidths((prev) => ({
|
|
...prev,
|
|
[resizing.field]: newWidth,
|
|
}));
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setResizing(null);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [resizing, columns, data]);
|
|
|
|
// 데이터 변경 감지 (필요시 활성화)
|
|
// useEffect(() => {
|
|
// console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행");
|
|
// }, [data]);
|
|
|
|
const handleCellEdit = (rowIndex: number, field: string, value: any) => {
|
|
const newRow = { ...data[rowIndex], [field]: value };
|
|
onRowChange(rowIndex, newRow);
|
|
};
|
|
|
|
// 전체 선택 체크박스 핸들러
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
// 모든 행 선택
|
|
const allIndices = new Set(data.map((_, index) => index));
|
|
onSelectionChange(allIndices);
|
|
} else {
|
|
// 전체 해제
|
|
onSelectionChange(new Set());
|
|
}
|
|
};
|
|
|
|
// 개별 행 선택 핸들러
|
|
const handleRowSelect = (rowIndex: number, checked: boolean) => {
|
|
const newSelection = new Set(selectedRows);
|
|
if (checked) {
|
|
newSelection.add(rowIndex);
|
|
} else {
|
|
newSelection.delete(rowIndex);
|
|
}
|
|
onSelectionChange(newSelection);
|
|
};
|
|
|
|
// 전체 선택 상태 계산
|
|
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
|
|
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
|
|
|
|
const renderCell = (
|
|
row: any,
|
|
column: RepeaterColumnConfig,
|
|
rowIndex: number
|
|
) => {
|
|
const isEditing =
|
|
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
|
const value = row[column.field];
|
|
|
|
// 계산 필드는 편집 불가
|
|
if (column.calculated || !column.editable) {
|
|
return (
|
|
<div className="px-2 py-1">
|
|
{column.type === "number"
|
|
? typeof value === "number"
|
|
? value.toLocaleString()
|
|
: value || "0"
|
|
: value || "-"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 편집 가능한 필드
|
|
switch (column.type) {
|
|
case "number":
|
|
return (
|
|
<Input
|
|
type="number"
|
|
value={value || ""}
|
|
onChange={(e) =>
|
|
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
|
}
|
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
|
/>
|
|
);
|
|
|
|
case "date":
|
|
// ISO 형식(2025-11-23T00:00:00.000Z)을 yyyy-mm-dd로 변환
|
|
const formatDateValue = (val: any): string => {
|
|
if (!val) return "";
|
|
// 이미 yyyy-mm-dd 형식이면 그대로 반환
|
|
if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) {
|
|
return val;
|
|
}
|
|
// ISO 형식이면 날짜 부분만 추출
|
|
if (typeof val === "string" && val.includes("T")) {
|
|
return val.split("T")[0];
|
|
}
|
|
// Date 객체이면 변환
|
|
if (val instanceof Date) {
|
|
return val.toISOString().split("T")[0];
|
|
}
|
|
return String(val);
|
|
};
|
|
|
|
return (
|
|
<Input
|
|
type="date"
|
|
value={formatDateValue(value)}
|
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
|
/>
|
|
);
|
|
|
|
case "select":
|
|
return (
|
|
<Select
|
|
value={value || ""}
|
|
onValueChange={(newValue) =>
|
|
handleCellEdit(rowIndex, column.field, newValue)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{column.selectOptions?.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
default: // text
|
|
return (
|
|
<Input
|
|
type="text"
|
|
value={value || ""}
|
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="border border-gray-200 bg-white">
|
|
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
|
<table className="w-full text-xs border-collapse">
|
|
<thead className="bg-gray-50 sticky top-0 z-10">
|
|
<tr>
|
|
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
|
|
<Checkbox
|
|
checked={isAllSelected}
|
|
// @ts-ignore - indeterminate는 HTML 속성
|
|
data-indeterminate={isIndeterminate}
|
|
onCheckedChange={handleSelectAll}
|
|
className={cn(
|
|
"border-gray-400",
|
|
isIndeterminate && "data-[state=checked]:bg-primary"
|
|
)}
|
|
/>
|
|
</th>
|
|
{columns.map((col) => {
|
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
|
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
|
const activeOption = hasDynamicSource
|
|
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
|
: null;
|
|
|
|
return (
|
|
<th
|
|
key={col.field}
|
|
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
|
|
style={{ width: `${columnWidths[col.field]}px` }}
|
|
onDoubleClick={() => handleDoubleClick(col.field)}
|
|
title="더블클릭하여 기본 너비로 되돌리기"
|
|
>
|
|
<div className="flex items-center justify-between pointer-events-none">
|
|
<div className="flex items-center gap-1 pointer-events-auto">
|
|
{hasDynamicSource ? (
|
|
<Popover
|
|
open={openPopover === col.field}
|
|
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
|
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1"
|
|
)}
|
|
>
|
|
<span>{col.label}</span>
|
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-auto min-w-[160px] p-1"
|
|
align="start"
|
|
sideOffset={4}
|
|
>
|
|
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
|
|
데이터 소스 선택
|
|
</div>
|
|
{col.dynamicDataSource!.options.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
onClick={() => {
|
|
onDataSourceChange?.(col.field, option.id);
|
|
setOpenPopover(null);
|
|
}}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
|
|
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
|
"focus:outline-none focus-visible:bg-accent",
|
|
activeOption?.id === option.id && "bg-accent/50"
|
|
)}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"h-3 w-3",
|
|
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<>
|
|
{col.label}
|
|
{col.required && <span className="text-red-500 ml-1">*</span>}
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* 리사이즈 핸들 */}
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
|
|
onMouseDown={(e) => handleMouseDown(e, col.field)}
|
|
title="드래그하여 너비 조정"
|
|
/>
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white">
|
|
{data.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={columns.length + 1}
|
|
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
|
|
>
|
|
추가된 항목이 없습니다
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
data.map((row, rowIndex) => (
|
|
<tr
|
|
key={rowIndex}
|
|
className={cn(
|
|
"hover:bg-blue-50/50 transition-colors",
|
|
selectedRows.has(rowIndex) && "bg-blue-50"
|
|
)}
|
|
>
|
|
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
|
<Checkbox
|
|
checked={selectedRows.has(rowIndex)}
|
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
|
className="border-gray-400"
|
|
/>
|
|
</td>
|
|
{columns.map((col) => (
|
|
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
|
|
{renderCell(row, col, rowIndex)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|