Files
sam-react-prod/src/components/organisms/DataTable.tsx
byeongcheolryu ded0bc2439 fix: TypeScript 타입 오류 수정 및 설정 페이지 추가
- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext)
- HeadersInit → Record<string, string> 타입 변경
- Zustand useShallow 마이그레이션 (zustand/react/shallow)
- DataTable, ListPageTemplate 제네릭 타입 제약 추가
- 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한)
- HR 관리 페이지 추가 (급여, 휴가)
- 단가관리 페이지 리팩토링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:07:47 +09:00

316 lines
9.6 KiB
TypeScript

"use client";
import { ReactNode } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, LucideIcon } from "lucide-react";
import { StatusBadge, StatusType } from "@/components/molecules/StatusBadge";
import { IconWithBadge } from "@/components/molecules/IconWithBadge";
import { TableActions, TableAction } from "@/components/molecules/TableActions";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
// 셀 타입 정의
export type CellType =
| "text" // 일반 텍스트
| "number" // 숫자 (우측 정렬)
| "currency" // 통화 (₩ 표시)
| "date" // 날짜
| "datetime" // 날짜 + 시간
| "status" // 상태 배지
| "badge" // 일반 배지
| "icon" // 아이콘
| "iconBadge" // 아이콘 + 배지
| "actions" // 액션 버튼들
| "custom"; // 커스텀 렌더링
export interface Column<T> {
key: keyof T | string;
label: string;
type?: CellType;
width?: string;
align?: "left" | "center" | "right";
sortable?: boolean;
className?: string;
// 타입별 설정
statusConfig?: {
showDot?: boolean;
};
badgeConfig?: {
variant?: "default" | "secondary" | "destructive" | "outline";
className?: string;
};
iconConfig?: {
iconColor?: string;
iconBgColor?: string;
size?: "sm" | "md" | "lg";
};
currencyConfig?: {
locale?: string;
currency?: string;
};
dateConfig?: {
format?: "short" | "long" | "relative";
};
// 커스텀 렌더링
render?: (value: any, row: T, index?: number) => ReactNode;
// 값 변환
format?: (value: any) => string | number;
}
interface DataTableProps<T extends object> {
columns: Column<T>[];
data: T[];
keyField: keyof T;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
pagination?: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showInfo?: boolean;
};
striped?: boolean;
hoverable?: boolean;
compact?: boolean;
}
// 셀 렌더러
function renderCell<T>(column: Column<T>, value: any, row: T, index?: number): ReactNode {
// 값 포맷팅 또는 render 호출
let formattedValue: any;
if (column.render) {
formattedValue = column.render(value, row, index);
} else if (column.format) {
formattedValue = column.format(value);
} else {
formattedValue = value;
}
// 타입별 렌더링
switch (column.type) {
case "number":
return <span className="font-mono">{formattedValue?.toLocaleString()}</span>;
case "currency":
const locale = column.currencyConfig?.locale || "ko-KR";
const currency = column.currencyConfig?.currency || "KRW";
const currencyValue = typeof formattedValue === "number"
? formattedValue.toLocaleString(locale, { style: "currency", currency })
: formattedValue;
return <span className="font-mono">{currencyValue}</span>;
case "date":
if (!formattedValue) return "-";
const dateValue = new Date(formattedValue);
return <span className="text-sm">{dateValue.toLocaleDateString("ko-KR")}</span>;
case "datetime":
if (!formattedValue) return "-";
const datetimeValue = new Date(formattedValue);
return (
<span className="text-sm">
{datetimeValue.toLocaleDateString("ko-KR")} {datetimeValue.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })}
</span>
);
case "status":
return (
<StatusBadge
label={formattedValue as string}
variant={formattedValue as StatusType}
showDot={column.statusConfig?.showDot}
/>
);
case "badge":
return (
<Badge
variant={column.badgeConfig?.variant}
className={column.badgeConfig?.className}
>
{formattedValue}
</Badge>
);
case "icon":
const IconComponent = formattedValue as LucideIcon;
return IconComponent ? (
<IconWithBadge
icon={IconComponent}
iconColor={column.iconConfig?.iconColor}
iconBgColor={column.iconConfig?.iconBgColor}
size={column.iconConfig?.size}
/>
) : null;
case "iconBadge":
// value should be { icon, label, badge }
if (typeof formattedValue === "object" && formattedValue.icon) {
return (
<IconWithBadge
icon={formattedValue.icon}
label={formattedValue.label}
badge={formattedValue.badge}
iconColor={column.iconConfig?.iconColor}
iconBgColor={column.iconConfig?.iconBgColor}
size={column.iconConfig?.size}
/>
);
}
return formattedValue;
case "actions":
// render 함수가 TableAction[] 배열을 반환해야 함
const actions = Array.isArray(formattedValue) ? formattedValue : [];
return actions.length > 0 ? <TableActions actions={actions} /> : null;
case "custom":
// 커스텀 타입은 이미 render 함수에서 처리됨
return formattedValue;
case "text":
default:
return formattedValue ?? "-";
}
}
// 정렬 함수
function getAlignClass(column: Column<any>): string {
if (column.align) {
return column.align === "center" ? "text-center" : column.align === "right" ? "text-right" : "text-left";
}
// 타입별 기본 정렬
switch (column.type) {
case "number":
case "currency":
case "actions":
return "text-right";
case "status":
case "badge":
case "icon":
case "iconBadge":
return "text-center";
default:
return "text-left";
}
}
export function DataTable<T extends object>({
columns,
data,
keyField,
onRowClick,
loading,
emptyMessage = "데이터가 없습니다.",
pagination,
striped = false,
hoverable = true,
compact = false
}: DataTableProps<T>) {
return (
<Card className="hidden md:block">
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={String(column.key)}
className={cn(
getAlignClass(column),
column.className,
column.width && `w-[${column.width}]`
)}
>
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
...
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
) : (
data.map((row, rowIndex) => (
<TableRow
key={row[keyField] ? String(row[keyField]) : `row-${rowIndex}`}
onClick={() => onRowClick?.(row)}
className={cn(
onRowClick && "cursor-pointer",
hoverable && "hover:bg-muted/50",
striped && rowIndex % 2 === 1 && "bg-muted/20",
compact && "h-10"
)}
>
{columns.map((column) => {
const value = column.key in row ? row[column.key as keyof T] : null;
return (
<TableCell
key={String(column.key)}
className={cn(
getAlignClass(column),
column.className,
compact && "py-2"
)}
>
{renderCell(column, value, row, rowIndex)}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">
{pagination.showInfo !== false && (
<p className="text-sm text-muted-foreground">
{pagination.currentPage} / {pagination.totalPages}
</p>
)}
<div className="flex gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => pagination.onPageChange(pagination.currentPage - 1)}
disabled={pagination.currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => pagination.onPageChange(pagination.currentPage + 1)}
disabled={pagination.currentPage === pagination.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}