- 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>
316 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
} |