Files
vexplor_dev/frontend/components/common/EDataTable.tsx
kjs 6ddc84f285 Update date handling in inventory and sales order pages
- Refactored date handling in the InventoryStatusPage to use `toLocaleString` for transaction dates and last in dates, ensuring correct timezone formatting.
- Introduced FormDatePicker in SalesOrderPage for date inputs, enhancing user experience with automatic formatting and improved date handling.
- Added a checkbox for filtering items by customer in SalesOrderPage, allowing users to view only items registered for the selected customer.

This update improves date accuracy and user interaction in the inventory and sales order modules.
2026-04-29 18:20:01 +09:00

940 lines
36 KiB
TypeScript

"use client";
/**
* EDataTable — 직접 구현 페이지용 공통 데이터 테이블 컴포넌트
*
* 프리셋 디자인 규격(Type A~F) 기반, shadcn/ui 위에 구축.
* 기능: 정렬, 헤더 필터, 컬럼 드래그 이동, 인라인 편집, 체크박스, 페이지네이션
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import {
Filter, Check, Search, X, Loader2, Inbox, GripVertical,
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
// ─── 타입 ───
export interface EDataTableColumn<T = any> {
key: string;
label: string;
width?: string;
minWidth?: string;
align?: "left" | "center" | "right";
sortable?: boolean;
filterable?: boolean;
editable?: boolean;
inputType?: "text" | "number" | "date" | "select";
selectOptions?: { value: string; label: string }[];
formatNumber?: boolean;
truncate?: boolean;
render?: (value: any, row: T, rowIndex: number) => React.ReactNode;
}
export interface SortState {
key: string;
direction: "asc" | "desc";
}
export interface EDataTableProps<T extends Record<string, any> = any> {
columns: EDataTableColumn<T>[];
data: T[];
rowKey?: (row: T) => string;
loading?: boolean;
emptyMessage?: string;
emptyIcon?: React.ReactNode;
selectedId?: string | null;
onSelect?: (id: string | null) => void;
showCheckbox?: boolean;
checkedIds?: string[];
onCheckedChange?: (ids: string[]) => void;
checkboxClickOnly?: boolean;
onRowClick?: (row: T, index: number) => void;
onRowDoubleClick?: (row: T, index: number) => void;
onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: T) => void;
tableName?: string;
sort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
// ─── 초기 자동 정렬 ───
// defaultSortKey 미지정 + 데이터에 created_date 컬럼 존재 시 자동으로 created_date desc 적용
// 사용자가 헤더 클릭하면 자동 default 재적용 차단
defaultSortKey?: string;
defaultSortDir?: "asc" | "desc";
draggableColumns?: boolean;
onColumnOrderChange?: (columns: EDataTableColumn<T>[]) => void;
columnOrderKey?: string;
showRowNumber?: boolean;
showPagination?: boolean;
defaultPageSize?: number;
// ─── 서버사이드 페이지네이션 모드 ───
// serverPagination=true 일 때: 내부 slice/filter/sort 미사용, data는 이미 해당 페이지 분량
serverPagination?: boolean;
serverCurrentPage?: number;
serverPageSize?: number;
serverTotalCount?: number;
onServerPageChange?: (page: number) => void;
onServerPageSizeChange?: (size: number) => void;
className?: string;
}
// ─── 유틸 ───
const fmtNum = (val: any) => {
if (val == null || val === "") return "";
const n = Number(String(val).replace(/,/g, ""));
if (isNaN(n)) return String(val);
return n.toLocaleString();
};
const getRowId = (row: any, rowKey?: (row: any) => string) => {
if (rowKey) return rowKey(row);
return row.id ?? row._id ?? "";
};
// ─── SortableHeaderCell ───
function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
draggable,
}: {
col: EDataTableColumn;
sortKey: string | null;
sortDir: "asc" | "desc";
onSort: (key: string) => void;
headerFilterValues: Set<string>;
uniqueValues: string[];
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
draggable: boolean;
}) {
const [filterSearch, setFilterSearch] = useState("");
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: col.key, disabled: !draggable });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const isSorted = sortKey === col.key;
const hasFilter = headerFilterValues.size > 0;
const filteredUniqueValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<TableHead
ref={setNodeRef}
style={style}
className={cn(
col.width, col.minWidth,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative overflow-hidden",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
>
<div className={cn(
"inline-flex items-center gap-1",
col.align === "right" && "justify-end w-full",
col.align === "center" && "justify-center w-full",
)}>
{/* 드래그 핸들 */}
{draggable && (
<div
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0"
>
<GripVertical className="h-3 w-3" />
</div>
)}
{/* 컬럼 라벨 + 정렬 */}
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
}}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortDir === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{/* 필터 아이콘 + Popover */}
{col.filterable !== false && uniqueValues.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {col.label}</span>
{hasFilter && (
<button onClick={() => onClearFilter(col.key)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredUniqueValues.slice(0, 100).map((val) => {
const isSelected = headerFilterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggleFilter(col.key, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredUniqueValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredUniqueValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</TableHead>
);
}
// ─── EDataTable ───
export function EDataTable<T extends Record<string, any> = any>({
columns: initialColumns,
data,
rowKey,
loading = false,
emptyMessage = "데이터가 없어요",
emptyIcon,
selectedId,
onSelect,
showCheckbox = false,
checkedIds = [],
onCheckedChange,
checkboxClickOnly = false,
onRowClick,
onRowDoubleClick,
onCellEdit,
tableName,
sort: externalSort,
onSortChange,
draggableColumns = true,
onColumnOrderChange,
columnOrderKey,
showRowNumber = false,
showPagination = true,
defaultPageSize = 50,
serverPagination = false,
serverCurrentPage,
serverPageSize,
serverTotalCount,
onServerPageChange,
onServerPageSizeChange,
defaultSortKey,
defaultSortDir,
className,
}: EDataTableProps<T>) {
const [columns, setColumns] = useState(initialColumns);
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
// 정렬 — 초기값으로 defaultSortKey 적용 (있는 경우)
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSortKey ? { key: defaultSortKey, direction: defaultSortDir ?? "asc" } : null
);
const sortState = externalSort !== undefined ? externalSort : internalSort;
// 자동 default 정렬 1회 가드 — 사용자 클릭 또는 명시 default 후 재적용 차단
const [autoDefaultApplied, setAutoDefaultApplied] = useState<boolean>(!!defaultSortKey);
// 헤더 필터
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 페이지네이션 — 서버사이드 모드면 외부 state 사용
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
const currentPage = serverPagination ? (serverCurrentPage ?? 1) : internalCurrentPage;
const pageSize = serverPagination ? (serverPageSize ?? defaultPageSize) : internalPageSize;
const setCurrentPage = (next: number | ((prev: number) => number)) => {
const resolved = typeof next === "function" ? (next as (p: number) => number)(currentPage) : next;
if (serverPagination) onServerPageChange?.(resolved);
else setInternalCurrentPage(resolved);
};
const setPageSize = (n: number) => {
if (serverPagination) onServerPageSizeChange?.(n);
else setInternalPageSize(n);
};
const [pageSizeInput, setPageSizeInput] = useState(String(serverPagination ? (serverPageSize ?? defaultPageSize) : defaultPageSize));
// 그룹 접기/펼치기
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const toggleGroup = (groupValue: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupValue)) next.delete(groupValue); else next.add(groupValue);
return next;
});
};
// 인라인 편집
const [editingCell, setEditingCell] = useState<{ rowId: string; colKey: string } | null>(null);
const [editValue, setEditValue] = useState("");
const editRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
// 자동 default 정렬 — defaultSortKey 미지정 + 외부 sort 모드 아닐 때 + 데이터에 created_date 있으면 1회 적용
useEffect(() => {
if (autoDefaultApplied) return;
if (defaultSortKey) { setAutoDefaultApplied(true); return; }
if (externalSort !== undefined) { setAutoDefaultApplied(true); return; } // 외부 정렬 제어 시 자동 적용 안 함
if (data.length > 0 && data[0]?.created_date !== undefined) {
setInternalSort({ key: "created_date", direction: "desc" });
setAutoDefaultApplied(true);
}
}, [data, defaultSortKey, externalSort, autoDefaultApplied]);
// localStorage에서 컬럼 순서 복원
useEffect(() => {
if (!columnOrderKey) return;
const saved = localStorage.getItem(`edatatable_col_order_${columnOrderKey}`);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order
.map((key) => initialColumns.find((c) => c.key === key))
.filter(Boolean) as EDataTableColumn<T>[];
const remaining = initialColumns.filter((c) => !order.includes(c.key));
setColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, [columnOrderKey]); // eslint-disable-line react-hooks/exhaustive-deps
// 컬럼별 고유값
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of columns) {
if (col.filterable === false) continue;
const values = new Set<string>();
data.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") {
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data, columns]);
// 드래그 완료
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
if (columnOrderKey) {
localStorage.setItem(`edatatable_col_order_${columnOrderKey}`, JSON.stringify(next.map((c) => c.key)));
}
onColumnOrderChange?.(next);
return next;
});
};
// 정렬
const handleSort = (key: string) => {
// 사용자가 헤더 클릭한 시점부터는 자동 default 재적용 차단
setAutoDefaultApplied(true);
const newSort: SortState | null = sortState?.key === key
? sortState.direction === "asc"
? { key, direction: "desc" }
: null
: { key, direction: "asc" };
if (onSortChange) {
onSortChange(newSort);
} else {
setInternalSort(newSort);
}
};
// 헤더 필터
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
// 필터 + 정렬 (서버사이드 모드면 원본 data 그대로 사용)
const processedData = useMemo(() => {
if (serverPagination) return data;
let result = [...data];
// 헤더 필터
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) =>
Object.entries(headerFilters).every(([colKey, values]) => {
if (values.size === 0) return true;
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
})
);
}
// 정렬 (외부 정렬이 아닌 경우만)
if (sortState && !onSortChange) {
const { key, direction } = sortState;
result.sort((a, b) => {
const rawA = a[key];
const rawB = b[key];
// NULLS LAST: null/undefined/"" 는 항상 뒤로 (방향 무관)
const aEmpty = rawA === null || rawA === undefined || rawA === "";
const bEmpty = rawB === null || rawB === undefined || rawB === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(rawA);
const nb = Number(rawB);
if (!isNaN(na) && !isNaN(nb) && rawA !== "" && rawB !== "") {
return direction === "asc" ? na - nb : nb - na;
}
return direction === "asc"
? String(rawA).localeCompare(String(rawB))
: String(rawB).localeCompare(String(rawA));
});
}
return result;
}, [data, headerFilters, sortState, onSortChange, serverPagination]);
// 필터/데이터 건수 변경 시 1페이지 리셋 (서버사이드에선 외부가 제어)
useEffect(() => {
if (!serverPagination) setCurrentPage(1);
}, [data.length, headerFilters, serverPagination]);
// 페이지네이션
const totalItems = serverPagination ? (serverTotalCount ?? data.length) : processedData.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(currentPage, totalPages);
useEffect(() => {
if (!serverPagination && currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages, serverPagination]);
const pageOffset = (safePage - 1) * pageSize;
const paginatedDataRaw = serverPagination
? processedData
: showPagination
? processedData.slice(pageOffset, pageOffset + pageSize)
: processedData;
// 접힌 그룹의 데이터 행 숨김
const paginatedData = useMemo(() => {
if (collapsedGroups.size === 0) return paginatedDataRaw;
let currentGroup: string | null = null;
return paginatedDataRaw.filter((row) => {
if ((row as any)._isGroupHeader) {
currentGroup = (row as any)._groupValue;
return true; // 헤더는 항상 표시
}
if ((row as any)._isGroupSummary) {
return !collapsedGroups.has((row as any)._groupValue);
}
// 일반 행: 현재 그룹이 접혀있으면 숨김
return !currentGroup || !collapsedGroups.has(currentGroup);
});
}, [paginatedDataRaw, collapsedGroups]);
const applyPageSize = () => {
const n = parseInt(pageSizeInput, 10);
if (!isNaN(n) && n >= 1) {
setPageSize(n);
setCurrentPage(1);
setPageSizeInput(String(n));
} else {
setPageSizeInput(String(pageSize));
}
};
const getPageNumbers = () => {
const delta = 2;
let start = Math.max(1, safePage - delta);
let end = Math.min(totalPages, safePage + delta);
if (end - start < delta * 2) {
if (start === 1) end = Math.min(totalPages, start + delta * 2);
else if (end === totalPages) start = Math.max(1, end - delta * 2);
}
const pages: (number | "...")[] = [];
if (start > 1) { pages.push(1); if (start > 2) pages.push("..."); }
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages) { if (end < totalPages - 1) pages.push("..."); pages.push(totalPages); }
return pages;
};
// 인라인 편집
const startEdit = (rowId: string, colKey: string, currentVal: any) => {
const col = columns.find((c) => c.key === colKey);
if (!col?.editable) return;
setEditingCell({ rowId, colKey });
setEditValue(currentVal != null ? String(currentVal) : "");
};
const saveEdit = useCallback(async () => {
if (!editingCell) return;
const { rowId, colKey } = editingCell;
const row = paginatedData.find((r) => getRowId(r, rowKey) === rowId);
if (!row) { setEditingCell(null); return; }
const originalVal = String(row[colKey] ?? "");
if (originalVal === editValue) { setEditingCell(null); return; }
if (tableName && row.id) {
try {
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: row.id },
updatedData: { [colKey]: editValue || null },
});
(row as any)[colKey] = editValue;
toast.success("저장되었어요");
} catch {
toast.error("저장에 실패했어요");
setEditingCell(null);
return;
}
}
onCellEdit?.(rowId, colKey, editValue, row as T);
setEditingCell(null);
}, [editingCell, editValue, paginatedData, tableName, onCellEdit, rowKey]);
const cancelEdit = () => setEditingCell(null);
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { e.preventDefault(); saveEdit(); }
else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
else if (e.key === "Tab") { e.preventDefault(); saveEdit(); }
};
useEffect(() => {
if (editingCell && editRef.current) {
editRef.current.focus();
if ("select" in editRef.current) editRef.current.select();
}
}, [editingCell]);
// 체크박스
const allChecked = processedData.length > 0 && checkedIds.length === processedData.length;
// colSpan 계산
const colSpan = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0);
// 셀 렌더링
const renderCell = (row: T, col: EDataTableColumn<T>, rowIdx: number) => {
const id = getRowId(row, rowKey);
const isEditing = editingCell?.rowId === id && editingCell?.colKey === col.key;
const val = row[col.key];
// 편집 모드
if (isEditing) {
if (col.inputType === "select" && col.selectOptions) {
return (
<select
ref={editRef as any}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={() => saveEdit()}
className="h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary"
>
<option value=""></option>
{col.selectOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
return (
<input
ref={editRef as any}
type={col.inputType === "number" ? "number" : col.inputType === "date" ? "date" : "text"}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={() => saveEdit()}
className={cn(
"h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary",
col.align === "right" && "text-right"
)}
/>
);
}
// 커스텀 렌더러
if (col.render) {
return col.render(val, row, rowIdx);
}
// 기본 렌더링
let display: React.ReactNode = val ?? "";
if (col.formatNumber || col.inputType === "number") display = fmtNum(val);
return (
<span
className={cn(
col.truncate !== false && "block truncate",
col.align === "right" && "text-right w-full inline-block",
col.align === "center" && "text-center w-full inline-block",
)}
title={String(val ?? "")}
>
{display}
</span>
);
};
return (
<div className={cn("flex flex-col h-full flex-1 min-h-0", className)}>
<div className="flex-1 min-h-0 overflow-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table noWrapper className="min-w-max">
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted h-10">
{/* 체크박스 */}
{showCheckbox && (
<TableHead className="w-10 text-center">
<Checkbox
checked={allChecked}
onCheckedChange={(checked) => {
onCheckedChange?.(checked ? processedData.map((r) => getRowId(r, rowKey)) : []);
}}
/>
</TableHead>
)}
{/* 행번호 */}
{showRowNumber && (
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
#
</TableHead>
)}
{/* 데이터 컬럼 */}
{columns.map((col) => (
<SortableHeaderCell
key={col.key}
col={col}
sortKey={sortState?.key ?? null}
sortDir={sortState?.direction ?? "asc"}
onSort={handleSort}
headerFilterValues={headerFilters[col.key] || new Set()}
uniqueValues={columnUniqueValues[col.key] || []}
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
draggable={draggableColumns}
/>
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={colSpan} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={colSpan} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
{emptyIcon || <Inbox className="h-8 w-8 opacity-30" />}
<span className="text-sm">{emptyMessage}</span>
</div>
</TableCell>
</TableRow>
) : (
paginatedData.map((row, rowIdx) => {
// 그룹 헤더 행 처리
if ((row as any)._isGroupHeader) {
const gv = (row as any)._groupValue || "";
const gc = (row as any)._groupCount || 0;
const isCollapsed = collapsedGroups.has(gv);
const totalCols = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0);
return (
<TableRow key={`group-${gv}-${rowIdx}`} className="bg-primary/5 hover:bg-primary/10 cursor-pointer border-t-2 border-primary/20" onClick={() => toggleGroup(gv)}>
<TableCell colSpan={totalCols} className="py-2 px-3">
<div className="flex items-center gap-2">
{isCollapsed ? <ChevronRight className="h-4 w-4 text-primary" /> : <ChevronDown className="h-4 w-4 text-primary" />}
<span className="text-sm font-semibold text-primary">{gv}</span>
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary">{gc}</Badge>
</div>
</TableCell>
</TableRow>
);
}
// 그룹 소계 행 처리
if ((row as any)._isGroupSummary) {
const gv = (row as any)._groupValue || "";
if (collapsedGroups.has(gv)) return null;
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
{showCheckbox && <TableCell />}
{showRowNumber && <TableCell />}
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(
typeof row[col.key] === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary",
col.width, col.minWidth,
)}
>
{typeof row[col.key] === "number" ? Number(row[col.key]).toLocaleString() : (row[col.key] || "")}
</TableCell>
))}
</TableRow>
);
}
const id = getRowId(row, rowKey);
const isSelected = selectedId != null && String(selectedId) === String(id);
const isChecked = checkedIds.includes(id);
return (
<TableRow
key={id || rowIdx}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
isSelected
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
: isChecked
? "bg-muted/50"
: "hover:bg-accent"
)}
onClick={() => {
onSelect?.(id);
onRowClick?.(row, pageOffset + rowIdx);
if (showCheckbox && onCheckedChange && !checkboxClickOnly) {
const next = checkedIds.includes(id)
? checkedIds.filter((cid) => cid !== id)
: [...checkedIds, id];
onCheckedChange(next);
}
}}
onDoubleClick={() => onRowDoubleClick?.(row, pageOffset + rowIdx)}
>
{showCheckbox && (
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
const next = checked
? [...checkedIds, id]
: checkedIds.filter((cid) => cid !== id);
onCheckedChange?.(next);
}}
/>
</TableCell>
)}
{showRowNumber && (
<TableCell className="text-center text-[11px] text-muted-foreground font-mono">
{pageOffset + rowIdx + 1}
</TableCell>
)}
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(
col.width, col.minWidth,
col.editable && "cursor-text",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
onDoubleClick={(e) => {
if (col.editable) {
e.stopPropagation();
startEdit(id, col.key, row[col.key]);
}
}}
>
{renderCell(row, col, pageOffset + rowIdx)}
</TableCell>
))}
</TableRow>
);
})
)}
</TableBody>
</Table>
</DndContext>
</div>
{/* 페이지네이션 */}
{showPagination && (
<div className="flex items-center justify-between border-t px-4 py-2.5 text-xs text-muted-foreground shrink-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
<span></span>
</div>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
value={pageSizeInput}
onChange={(e) => setPageSizeInput(e.target.value)}
onBlur={applyPageSize}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
className="h-7 w-16 text-center text-xs"
/>
<span> </span>
</div>
</div>
<div className="flex items-center gap-0.5">
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronsLeft className="h-3.5 w-3.5" />
</button>
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{getPageNumbers().map((page, idx) =>
page === "..." ? (
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
) : (
<button key={page} onClick={() => setCurrentPage(page as number)}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
{page}
</button>
)
)}
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronsRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
max={totalPages}
placeholder={String(safePage)}
className="h-7 w-14 text-center text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
const val = parseInt((e.target as HTMLInputElement).value, 10);
if (!isNaN(val) && val >= 1 && val <= totalPages) {
setCurrentPage(val);
(e.target as HTMLInputElement).value = "";
(e.target as HTMLInputElement).blur();
}
}
}}
onBlur={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= totalPages) {
setCurrentPage(val);
}
e.target.value = "";
}}
/>
<span>/ {totalPages} </span>
</div>
</div>
)}
</div>
);
}