- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed) - DynamicTableSection 및 TableCellRenderer 추가 - 필드 프리셋 및 설정 구조 분리 - 컴포넌트 레지스트리 개발 도구 페이지 추가 - UniversalListPage 개선 - 근태관리 코드 정리 - 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
/**
|
|
* DynamicTableSection
|
|
* config 기반 범용 테이블 섹션 (공정, 품질검사, 구매처, 공정표, 배차 등)
|
|
*
|
|
* API 연동 시: GET/PUT /v1/items/{itemId}/section-data/{sectionId}
|
|
* API 연동 전: rows를 폼 상태(formData)에 로컬 관리
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useCallback } from 'react';
|
|
import { Plus, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { TableCellRenderer } from './TableCellRenderer';
|
|
import type { ItemSectionResponse } from '@/types/item-master-api';
|
|
import type { TableConfig } from '../types';
|
|
|
|
export interface DynamicTableSectionProps {
|
|
section: {
|
|
section: ItemSectionResponse;
|
|
orderNo: number;
|
|
};
|
|
tableConfig: TableConfig;
|
|
rows: Record<string, unknown>[];
|
|
onRowsChange: (rows: Record<string, unknown>[]) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export default function DynamicTableSection({
|
|
section,
|
|
tableConfig,
|
|
rows,
|
|
onRowsChange,
|
|
disabled,
|
|
}: DynamicTableSectionProps) {
|
|
const { columns, minRows, maxRows, summaryRow } = tableConfig;
|
|
|
|
// 행 추가
|
|
const handleAddRow = useCallback(() => {
|
|
if (maxRows && rows.length >= maxRows) return;
|
|
const newRow: Record<string, unknown> = {};
|
|
columns.forEach(col => {
|
|
newRow[col.key] = null;
|
|
});
|
|
onRowsChange([...rows, newRow]);
|
|
}, [rows, columns, maxRows, onRowsChange]);
|
|
|
|
// 행 삭제
|
|
const handleRemoveRow = useCallback((index: number) => {
|
|
if (minRows && rows.length <= minRows) return;
|
|
onRowsChange(rows.filter((_, i) => i !== index));
|
|
}, [rows, minRows, onRowsChange]);
|
|
|
|
// 셀 값 변경
|
|
const handleCellChange = useCallback((rowIndex: number, columnKey: string, value: unknown) => {
|
|
const updated = rows.map((row, i) =>
|
|
i === rowIndex ? { ...row, [columnKey]: value } : row
|
|
);
|
|
onRowsChange(updated);
|
|
}, [rows, onRowsChange]);
|
|
|
|
// 요약행 계산
|
|
const computeSummary = (columnKey: string, type: string): string | number => {
|
|
const values = rows.map(r => Number(r[columnKey]) || 0);
|
|
switch (type) {
|
|
case 'sum': return values.reduce((a, b) => a + b, 0);
|
|
case 'avg': return values.length > 0
|
|
? Math.round(values.reduce((a, b) => a + b, 0) / values.length * 100) / 100
|
|
: 0;
|
|
case 'count': return rows.length;
|
|
default: return '';
|
|
}
|
|
};
|
|
|
|
const canAdd = !maxRows || rows.length < maxRows;
|
|
const canRemove = !minRows || rows.length > minRows;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-base font-medium">
|
|
{section.section.title}
|
|
</CardTitle>
|
|
{section.section.description && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{section.section.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{!disabled && canAdd && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAddRow}
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
행 추가
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{rows.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
데이터가 없습니다. "행 추가" 버튼으로 항목을 추가하세요.
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12 text-center">#</TableHead>
|
|
{columns.map(col => (
|
|
<TableHead
|
|
key={col.key}
|
|
style={col.width ? { width: col.width } : undefined}
|
|
>
|
|
{col.label}
|
|
{col.isRequired && <span className="text-red-500"> *</span>}
|
|
</TableHead>
|
|
))}
|
|
{!disabled && <TableHead className="w-12" />}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rows.map((row, rowIndex) => (
|
|
<TableRow key={rowIndex}>
|
|
<TableCell className="text-center text-muted-foreground text-sm">
|
|
{rowIndex + 1}
|
|
</TableCell>
|
|
{columns.map(col => (
|
|
<TableCell key={col.key} className="p-1">
|
|
<TableCellRenderer
|
|
column={col}
|
|
value={row[col.key] as any}
|
|
onChange={(val) => handleCellChange(rowIndex, col.key, val)}
|
|
disabled={disabled}
|
|
rowIndex={rowIndex}
|
|
/>
|
|
</TableCell>
|
|
))}
|
|
{!disabled && (
|
|
<TableCell className="p-1 text-center">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
disabled={!canRemove}
|
|
onClick={() => handleRemoveRow(rowIndex)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</Button>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
{/* 요약행 */}
|
|
{summaryRow && summaryRow.length > 0 && (
|
|
<TableRow className="bg-muted/50 font-medium">
|
|
<TableCell className="text-center text-sm">합계</TableCell>
|
|
{columns.map(col => {
|
|
const summary = summaryRow.find(s => s.columnKey === col.key);
|
|
return (
|
|
<TableCell key={col.key} className="p-2 text-sm">
|
|
{summary
|
|
? summary.type === 'label'
|
|
? summary.label || ''
|
|
: computeSummary(col.key, summary.type)
|
|
: ''}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
{!disabled && <TableCell />}
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|