fix: 품목기준관리 실시간 동기화 수정
- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영 - 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결) - 항목 수정 기능 추가 (useTemplateManagement) - 실시간 동기화 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
365
src/components/items/ItemForm/BOMSection.tsx
Normal file
365
src/components/items/ItemForm/BOMSection.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* BOMSection - 부품 구성 (BOM) 섹션
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Check, Package, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import type { BOMLine } from '@/types/item';
|
||||
import type { BOMSearchState } from './types';
|
||||
|
||||
export interface BOMSectionProps {
|
||||
bomLines: BOMLine[];
|
||||
setBomLines: (lines: BOMLine[]) => void;
|
||||
bomSearchStates: Record<string, BOMSearchState>;
|
||||
setBomSearchStates: (states: Record<string, BOMSearchState>) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function BOMSection({
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
isSubmitting,
|
||||
}: BOMSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>부품 구성 (BOM)</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines([...bomLines, newLine]);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
BOM 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bomLines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-16 w-16 text-muted-foreground mb-4 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
아직 부품 구성이 추가되지 않았습니다
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
품목의 구성 부품, 원자재, 부자재를 추가할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[280px]">품목코드 / 품목명 입력</TableHead>
|
||||
<TableHead className="w-[180px]">품목명</TableHead>
|
||||
<TableHead className="w-[150px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">재질</TableHead>
|
||||
<TableHead className="w-20">수량</TableHead>
|
||||
<TableHead className="w-16">단위</TableHead>
|
||||
<TableHead className="w-24 text-right">단가</TableHead>
|
||||
<TableHead className="w-[180px]">비고</TableHead>
|
||||
<TableHead className="w-16">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomLines.map((line) => {
|
||||
// 각 라인별 검색 상태 가져오기
|
||||
const searchState = bomSearchStates[line.id] || { searchValue: '', isOpen: false };
|
||||
const searchValue = searchState.searchValue;
|
||||
const searchOpen = searchState.isOpen;
|
||||
|
||||
// TODO: 실제 itemMasters 데이터로 교체 필요
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const availableItems: any[] = [];
|
||||
|
||||
return (
|
||||
<Fragment key={line.id}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, isOpen: open },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명 입력..."
|
||||
value={line.childItemCode || searchValue}
|
||||
onChange={(e) => {
|
||||
// 단순 입력만 처리 (서버 자동완성 준비)
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: e.target.value },
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
readOnly={!!line.childItemCode}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
{line.childItemCode && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: true },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0 opacity-0 data-[state=open]:opacity-100 transition-opacity duration-150 delay-200"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
disableSlideAnimation
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="품목코드 또는 품목명 검색..."
|
||||
value={searchValue}
|
||||
onValueChange={(value) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.itemCode} ${item.itemName}`}
|
||||
onSelect={() => {
|
||||
// TODO: 품목 선택 시 데이터 채우기 로직
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id
|
||||
? {
|
||||
...l,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0, // TODO: pricing에서 가져오기
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart
|
||||
? item.bendingDiagram
|
||||
: undefined,
|
||||
}
|
||||
: l
|
||||
)
|
||||
);
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: false },
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
<span className="text-sm">{item.itemName}</span>
|
||||
{item.specification && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({item.specification})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{item.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.childItemName || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{line.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<Input
|
||||
value={line.material || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, material: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="재질"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, quantity: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{line.unit}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.unitPrice || 0}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
className="w-full text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={line.note || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, note: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="비고"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomLines(bomLines.filter((l) => l.id !== line.id));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 절곡품인 경우 전개도 정보 표시 */}
|
||||
{line.isBending && line.bendingDiagram && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="bg-blue-50 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-700">
|
||||
절곡품 전개도 정보
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 */}
|
||||
<div>
|
||||
<Label className="text-xs mb-2 block">전개도 이미지</Label>
|
||||
<div className="border rounded-lg p-2 bg-white">
|
||||
<img
|
||||
src={line.bendingDiagram}
|
||||
alt="절곡 전개도"
|
||||
className="max-w-full h-auto max-h-[300px] object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
367
src/components/items/ItemForm/BendingDiagramSection.tsx
Normal file
367
src/components/items/ItemForm/BendingDiagramSection.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* BendingDiagramSection - 절곡품/조립품 전개도 섹션
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileImage, Plus, Trash2, X } from 'lucide-react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface BendingDiagramSectionProps {
|
||||
selectedPartType: string;
|
||||
bendingDiagramInputMethod: 'file' | 'drawing';
|
||||
setBendingDiagramInputMethod: (method: 'file' | 'drawing') => void;
|
||||
bendingDiagram: string;
|
||||
setBendingDiagram: (diagram: string) => void;
|
||||
setBendingDiagramFile: (file: File | null) => void;
|
||||
setIsDrawingOpen: (open: boolean) => void;
|
||||
bendingDetails: BendingDetail[];
|
||||
setBendingDetails: (details: BendingDetail[]) => void;
|
||||
setWidthSum: (sum: string) => void;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function BendingDiagramSection({
|
||||
selectedPartType,
|
||||
bendingDiagramInputMethod,
|
||||
setBendingDiagramInputMethod,
|
||||
bendingDiagram,
|
||||
setBendingDiagram,
|
||||
setBendingDiagramFile,
|
||||
setIsDrawingOpen,
|
||||
bendingDetails,
|
||||
setBendingDetails,
|
||||
setWidthSum,
|
||||
setValue,
|
||||
isSubmitting,
|
||||
}: BendingDiagramSectionProps) {
|
||||
// 폭 합계 업데이트 헬퍼
|
||||
const updateWidthSum = (details: BendingDetail[]) => {
|
||||
const totalSum = details.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0);
|
||||
setWidthSum(totalSum.toString());
|
||||
setValue('length', totalSum.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileImage className="h-5 w-5" />
|
||||
{selectedPartType === 'ASSEMBLY' ? '조립품 전개도 (바라시)' : '절곡품 전개도 (바라시)'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 입력방식 선택 */}
|
||||
<div>
|
||||
<Label>입력방식 선택</Label>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="input-file"
|
||||
name="bendingInputMethod"
|
||||
value="file"
|
||||
checked={bendingDiagramInputMethod === 'file'}
|
||||
onChange={(e) => setBendingDiagramInputMethod(e.target.value as 'file')}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="input-file" className="cursor-pointer font-normal">
|
||||
파일 선택 (이미지 파일 업로드)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="input-drawing"
|
||||
name="bendingInputMethod"
|
||||
value="drawing"
|
||||
checked={bendingDiagramInputMethod === 'drawing'}
|
||||
onChange={(e) => setBendingDiagramInputMethod(e.target.value as 'drawing')}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="input-drawing" className="cursor-pointer font-normal">
|
||||
드로잉 (직접 그리기)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 전개도 이미지를 파일로 업로드하거나 직접 그릴 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 파일 선택 방식 */}
|
||||
{bendingDiagramInputMethod === 'file' && (
|
||||
<div>
|
||||
<Label>파일 선택</Label>
|
||||
<div className="mt-2 space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && typeof window !== 'undefined') {
|
||||
setBendingDiagramFile(file);
|
||||
const reader = new window.FileReader();
|
||||
reader.onloadend = () => {
|
||||
setBendingDiagram(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* {selectedPartType === 'ASSEMBLY'
|
||||
? '조립품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'
|
||||
: '절곡품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 미리보기 */}
|
||||
{bendingDiagram && (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">미리보기</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBendingDiagram('');
|
||||
setBendingDiagramFile(null);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
전개도 삭제
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={bendingDiagram}
|
||||
alt="전개도 미리보기"
|
||||
className="max-w-full h-auto max-h-96 mx-auto border rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드로잉 방식 */}
|
||||
{bendingDiagramInputMethod === 'drawing' && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsDrawingOpen(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<FileImage className="h-4 w-4 mr-2" />
|
||||
{bendingDiagram ? '전개도 수정' : '전개도 그리기'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* 캔버스에서 직접 전개도를 그릴 수 있습니다
|
||||
</p>
|
||||
|
||||
{/* 전개도 미리보기 */}
|
||||
{bendingDiagram && (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">미리보기</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setBendingDiagram('')}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
전개도 삭제
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={bendingDiagram}
|
||||
alt="전개도 미리보기"
|
||||
className="max-w-full h-auto max-h-96 mx-auto border rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전개도 상세 입력 (치수 계산) - BENDING 전용 */}
|
||||
{selectedPartType === 'BENDING' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>전개도 상세 입력 (치수 계산)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newId = `detail-${Date.now()}`;
|
||||
const newRow: BendingDetail = {
|
||||
id: newId,
|
||||
no: bendingDetails.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
aAngle: undefined,
|
||||
};
|
||||
setBendingDetails([...bendingDetails, newRow]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bendingDetails.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-center w-16">번호</th>
|
||||
<th className="px-3 py-2 text-center">입력값</th>
|
||||
<th className="px-3 py-2 text-center">연신율</th>
|
||||
<th className="px-3 py-2 text-center">계산값</th>
|
||||
<th className="px-3 py-2 text-center w-20">음영</th>
|
||||
<th className="px-3 py-2 text-center">A각</th>
|
||||
<th className="px-3 py-2 text-center w-16">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bendingDetails.map((detail, index) => {
|
||||
const calculated = detail.input + detail.elongation;
|
||||
|
||||
return (
|
||||
<tr key={detail.id} className={detail.shaded ? 'bg-gray-100' : ''}>
|
||||
<td className="px-3 py-2 text-center border-b">{detail.no}</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.input}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value);
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
input: isNaN(value) ? 0 : value,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.elongation}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
const value = e.target.value === '' ? -1 : parseFloat(e.target.value);
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
elongation: isNaN(value) ? -1 : value,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b bg-gray-50">
|
||||
{calculated.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detail.shaded}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
shaded: e.target.checked,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.aAngle || ''}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
aAngle: parseFloat(e.target.value) || undefined,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
placeholder="각도"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newDetails = bendingDetails
|
||||
.filter((_, i) => i !== index)
|
||||
.map((d, i) => ({ ...d, no: i + 1 }));
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t-2">
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-2 text-right font-semibold">
|
||||
폭 합계:
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center font-bold text-blue-600">
|
||||
{bendingDetails.length > 0
|
||||
? bendingDetails.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0).toFixed(1)
|
||||
: '0.0'} mm
|
||||
</td>
|
||||
<td colSpan={3}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
전개도 상세 데이터가 없습니다. "행 추가" 버튼을 클릭하여 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* 전개도의 각 구간별 치수를 입력하여 정확한 전개 길이를 계산할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
src/components/items/ItemForm/FormHeader.tsx
Normal file
62
src/components/items/ItemForm/FormHeader.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* FormHeader - 품목 폼 헤더 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Package, Save, X } from 'lucide-react';
|
||||
import type { ItemType } from '@/types/item';
|
||||
|
||||
interface FormHeaderProps {
|
||||
mode: 'create' | 'edit';
|
||||
selectedItemType: ItemType | '';
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function FormHeader({
|
||||
mode,
|
||||
selectedItemType,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
}: FormHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl">
|
||||
{mode === 'create' ? '품목 등록' : '품목 수정'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
품목 정보를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="gap-1 sm:gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">취소</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!selectedItemType || isSubmitting}
|
||||
className="gap-1 sm:gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/items/ItemForm/ValidationAlert.tsx
Normal file
50
src/components/items/ItemForm/ValidationAlert.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ValidationAlert - 폼 검증 에러 표시 컴포넌트
|
||||
*/
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { FIELD_NAME_MAP } from './constants';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface ValidationAlertProps {
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function ValidationAlert({ errors }: ValidationAlertProps) {
|
||||
const errorCount = Object.keys(errors).length;
|
||||
|
||||
if (errorCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({errorCount}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, error]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
const errorMessage = error?.message || '입력 오류';
|
||||
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {errorMessage}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
93
src/components/items/ItemForm/constants.ts
Normal file
93
src/components/items/ItemForm/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* ItemForm 상수 정의
|
||||
*/
|
||||
|
||||
// 부품 유형별 분류 체계
|
||||
export const PART_TYPE_CATEGORIES = {
|
||||
ASSEMBLY: {
|
||||
label: "조립 부품 (Assembly Part)",
|
||||
categories: [
|
||||
{ value: "guide_rail", label: "가이드레일", code: "R" },
|
||||
{ value: "case", label: "케이스", code: "C" },
|
||||
{ value: "bottom_finish", label: "하단마감재", code: "B" },
|
||||
]
|
||||
},
|
||||
BENDING: {
|
||||
label: "절곡 부품 (Bending Part)",
|
||||
categories: [
|
||||
{ value: "guide_rail_wall", label: "가이드레일(벽면형)", code: "R" },
|
||||
{ value: "guide_rail_side", label: "가이드레일(측면형)", code: "S" },
|
||||
{ value: "case", label: "케이스", code: "C" },
|
||||
{ value: "bottom_finish_screen", label: "하단마감재(스크린)", code: "B" },
|
||||
{ value: "bottom_finish_steel", label: "하단마감재(철재)", code: "T" },
|
||||
{ value: "l_bar", label: "L-Bar", code: "L" },
|
||||
{ value: "smoke_barrier", label: "연기차단재", code: "G" },
|
||||
]
|
||||
},
|
||||
PURCHASED: {
|
||||
label: "구매 부품 (Purchased Part)",
|
||||
categories: [
|
||||
{ value: "electric_opener", label: "전동개폐기", code: "E" },
|
||||
{ value: "motor", label: "모터", code: "M" },
|
||||
{ value: "chain", label: "체인", code: "CH" },
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
// 부품 분류별 종류 옵션
|
||||
export const PART_ITEM_NAMES: Record<string, Array<{value: string, label: string, code: string}>> = {
|
||||
guide_rail_wall: [
|
||||
{ value: "RM", label: "분체", code: "M" },
|
||||
{ value: "RT", label: "분체(철재)", code: "T" },
|
||||
{ value: "RC", label: "C형", code: "C" },
|
||||
{ value: "RD", label: "D형", code: "D" },
|
||||
{ value: "RS", label: "SUS 마감재", code: "S" },
|
||||
{ value: "RM2", label: "분체티딩", code: "M" },
|
||||
],
|
||||
guide_rail_side: [
|
||||
{ value: "SC", label: "C형", code: "C" },
|
||||
{ value: "SD", label: "D형", code: "D" },
|
||||
{ value: "SS", label: "SUS 마감재①", code: "S" },
|
||||
{ value: "SU", label: "SUS 마감재②", code: "U" },
|
||||
{ value: "SF", label: "전면부", code: "F" },
|
||||
{ value: "SP", label: "점검구", code: "P" },
|
||||
],
|
||||
case: [
|
||||
{ value: "CF", label: "전면부", code: "F" },
|
||||
{ value: "CP", label: "점검구", code: "P" },
|
||||
{ value: "CL", label: "린텔부", code: "L" },
|
||||
{ value: "CB", label: "후면코너부", code: "B" },
|
||||
],
|
||||
bottom_finish_screen: [
|
||||
{ value: "BS", label: "SUS", code: "S" },
|
||||
{ value: "BE", label: "EGI", code: "E" },
|
||||
],
|
||||
bottom_finish_steel: [
|
||||
{ value: "TS", label: "SUS", code: "S" },
|
||||
{ value: "TE", label: "EGI", code: "E" },
|
||||
],
|
||||
l_bar: [
|
||||
{ value: "LA", label: "스크린용", code: "A" },
|
||||
],
|
||||
smoke_barrier: [
|
||||
{ value: "GI", label: "화이바원단(W50)", code: "I" },
|
||||
{ value: "GI2", label: "화이바원단(W80)", code: "I" },
|
||||
],
|
||||
};
|
||||
|
||||
// 필드명 한글 매핑 (에러 메시지용)
|
||||
export const FIELD_NAME_MAP: Record<string, string> = {
|
||||
'productName': '상품명',
|
||||
'itemName': '품목명',
|
||||
'itemType': '품목 유형',
|
||||
'partType': '부품 유형',
|
||||
'category1': '품목명',
|
||||
'material': '재질',
|
||||
'length': '폭 합계',
|
||||
'bendingLength': '모양&길이',
|
||||
'sideSpecWidth': '측면 규격 (가로)',
|
||||
'sideSpecHeight': '측면 규격 (세로)',
|
||||
'assemblyLength': '길이',
|
||||
'specification': '규격',
|
||||
'unit': '단위',
|
||||
};
|
||||
77
src/components/items/ItemForm/context/ItemFormContext.tsx
Normal file
77
src/components/items/ItemForm/context/ItemFormContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* ItemFormContext - 품목 폼 상태 컨텍스트
|
||||
*
|
||||
* 하위 컴포넌트에서 공유되는 폼 상태 관리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { UseItemFormStateReturn } from '../hooks/useItemFormState';
|
||||
import type { UseBOMManagementReturn } from '../hooks/useBOMManagement';
|
||||
import type { UseBendingDetailsReturn } from '../hooks/useBendingDetails';
|
||||
|
||||
export interface ItemFormContextType {
|
||||
// React Hook Form
|
||||
form: UseFormReturn<CreateItemFormData>;
|
||||
|
||||
// 모드
|
||||
mode: 'create' | 'edit';
|
||||
|
||||
// 품목 유형
|
||||
selectedItemType: ItemType | '';
|
||||
setSelectedItemType: (type: ItemType | '') => void;
|
||||
|
||||
// 부품 유형
|
||||
selectedPartType: string;
|
||||
setSelectedPartType: (type: string) => void;
|
||||
|
||||
// 상태 훅
|
||||
formState: UseItemFormStateReturn;
|
||||
bomManagement: UseBOMManagementReturn;
|
||||
bendingDetails: UseBendingDetailsReturn;
|
||||
|
||||
// 품목코드 생성
|
||||
generateItemCode: () => string;
|
||||
|
||||
// 품목 유형 변경 핸들러
|
||||
handleItemTypeChange: (type: ItemType) => void;
|
||||
|
||||
// 제출 상태
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const ItemFormContext = createContext<ItemFormContextType | null>(null);
|
||||
|
||||
export interface ItemFormProviderProps {
|
||||
children: ReactNode;
|
||||
value: ItemFormContextType;
|
||||
}
|
||||
|
||||
export function ItemFormProvider({ children, value }: ItemFormProviderProps) {
|
||||
return (
|
||||
<ItemFormContext.Provider value={value}>
|
||||
{children}
|
||||
</ItemFormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useItemFormContext(): ItemFormContextType {
|
||||
const context = useContext(ItemFormContext);
|
||||
if (!context) {
|
||||
throw new Error('useItemFormContext must be used within an ItemFormProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택적으로 컨텍스트 사용 (컨텍스트가 없어도 에러 안 남)
|
||||
*/
|
||||
export function useOptionalItemFormContext(): ItemFormContextType | null {
|
||||
return useContext(ItemFormContext);
|
||||
}
|
||||
|
||||
export default ItemFormContext;
|
||||
12
src/components/items/ItemForm/context/index.ts
Normal file
12
src/components/items/ItemForm/context/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 품목 폼 컨텍스트 export
|
||||
*/
|
||||
|
||||
export {
|
||||
ItemFormProvider,
|
||||
useItemFormContext,
|
||||
useOptionalItemFormContext,
|
||||
default as ItemFormContext,
|
||||
} from './ItemFormContext';
|
||||
|
||||
export type { ItemFormContextType, ItemFormProviderProps } from './ItemFormContext';
|
||||
354
src/components/items/ItemForm/forms/MaterialForm.tsx
Normal file
354
src/components/items/ItemForm/forms/MaterialForm.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 원자재/부자재/소모품 (RM/SM/CS) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface MaterialFormProps {
|
||||
selectedItemType: ItemType;
|
||||
itemName: string;
|
||||
setItemName: (value: string) => void;
|
||||
selectedSpecification: string;
|
||||
setSelectedSpecification: (value: string) => void;
|
||||
materialStatus: string;
|
||||
setMaterialStatus: (value: string) => void;
|
||||
selectedUnit: string;
|
||||
setSelectedUnit: (value: string) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
getValues: UseFormGetValues<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function MaterialForm({
|
||||
selectedItemType,
|
||||
itemName,
|
||||
setItemName,
|
||||
selectedSpecification,
|
||||
setSelectedSpecification,
|
||||
materialStatus,
|
||||
setMaterialStatus,
|
||||
selectedUnit,
|
||||
setSelectedUnit,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
errors,
|
||||
}: MaterialFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{/* 원자재/부자재는 목록에서 선택, 소모품은 직접 입력 */}
|
||||
{selectedItemType === 'RM' ? (
|
||||
<>
|
||||
<Select
|
||||
value={itemName}
|
||||
onValueChange={(value) => {
|
||||
setItemName(value);
|
||||
setValue('itemName', value);
|
||||
// 품목명 변경 시 규격 초기화
|
||||
setSelectedSpecification('');
|
||||
setValue('specification', '');
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${value}-${spec}` : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.itemName ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="철판">철판</SelectItem>
|
||||
<SelectItem value="알루미늄">알루미늄</SelectItem>
|
||||
<SelectItem value="스테인리스">스테인리스</SelectItem>
|
||||
<SelectItem value="아연도금강판">아연도금강판</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : selectedItemType === 'SM' ? (
|
||||
<>
|
||||
<Select
|
||||
value={itemName}
|
||||
onValueChange={(value) => {
|
||||
setItemName(value);
|
||||
setValue('itemName', value);
|
||||
// 품목명 변경 시 규격 초기화
|
||||
setSelectedSpecification('');
|
||||
setValue('specification', '');
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${value}-${spec}` : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.itemName ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="볼트">볼트</SelectItem>
|
||||
<SelectItem value="너트">너트</SelectItem>
|
||||
<SelectItem value="와셔">와셔</SelectItem>
|
||||
<SelectItem value="나사">나사</SelectItem>
|
||||
<SelectItem value="앵커">앵커</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="품목명을 입력하세요"
|
||||
value={itemName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setItemName(newName);
|
||||
setValue('itemName', newName);
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${newName}-${spec}` : newName);
|
||||
}}
|
||||
className={errors.itemName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 규격(사양) */}
|
||||
{selectedItemType === 'CS' ? (
|
||||
<div>
|
||||
<Label htmlFor="specification">
|
||||
규격(사양) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="specification"
|
||||
placeholder="예: 면-L, 고급형, A4"
|
||||
{...register('specification', {
|
||||
onChange: (e) => {
|
||||
// 품목코드 자동생성
|
||||
const spec = e.target.value;
|
||||
const name = itemName || '';
|
||||
setValue('itemCode', name && spec ? `${name}-${spec}` : name);
|
||||
}
|
||||
})}
|
||||
className={errors.specification ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.specification && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.specification.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="specification">
|
||||
규격 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedSpecification}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSpecification(value);
|
||||
setValue('specification', value);
|
||||
// 품목코드 자동생성
|
||||
const name = itemName || '';
|
||||
setValue('itemCode', name && value ? `${name}-${value}` : name);
|
||||
}}
|
||||
disabled={!itemName}
|
||||
>
|
||||
<SelectTrigger id="specification" className={errors.specification ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder={itemName ? "규격을 선택하세요" : "품목명을 먼저 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedItemType === 'RM' && itemName === '철판' && (
|
||||
<>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.2T">1.2T</SelectItem>
|
||||
<SelectItem value="1.5T">1.5T</SelectItem>
|
||||
<SelectItem value="2.0T">2.0T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'RM' && itemName === '알루미늄' && (
|
||||
<>
|
||||
<SelectItem value="0.8T">0.8T</SelectItem>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.5T">1.5T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'RM' && itemName === '스테인리스' && (
|
||||
<>
|
||||
<SelectItem value="0.5T">0.5T</SelectItem>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.2T">1.2T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '볼트' && (
|
||||
<>
|
||||
<SelectItem value="M6x20">M6×20mm</SelectItem>
|
||||
<SelectItem value="M8x25">M8×25mm</SelectItem>
|
||||
<SelectItem value="M10x30">M10×30mm</SelectItem>
|
||||
<SelectItem value="M12x40">M12×40mm</SelectItem>
|
||||
<SelectItem value="M16x50">M16×50mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '너트' && (
|
||||
<>
|
||||
<SelectItem value="M6">M6</SelectItem>
|
||||
<SelectItem value="M8">M8</SelectItem>
|
||||
<SelectItem value="M10">M10</SelectItem>
|
||||
<SelectItem value="M12">M12</SelectItem>
|
||||
<SelectItem value="M16">M16</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '와셔' && (
|
||||
<>
|
||||
<SelectItem value="M6">M6</SelectItem>
|
||||
<SelectItem value="M8">M8</SelectItem>
|
||||
<SelectItem value="M10">M10</SelectItem>
|
||||
<SelectItem value="M12">M12</SelectItem>
|
||||
<SelectItem value="M16">M16</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '나사' && (
|
||||
<>
|
||||
<SelectItem value="4x20">4×20mm</SelectItem>
|
||||
<SelectItem value="5x25">5×25mm</SelectItem>
|
||||
<SelectItem value="6x30">6×30mm</SelectItem>
|
||||
<SelectItem value="8x40">8×40mm</SelectItem>
|
||||
<SelectItem value="10x50">10×50mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '앵커' && (
|
||||
<>
|
||||
<SelectItem value="6x30">6×30mm</SelectItem>
|
||||
<SelectItem value="8x40">8×40mm</SelectItem>
|
||||
<SelectItem value="10x50">10×50mm</SelectItem>
|
||||
<SelectItem value="12x60">12×60mm</SelectItem>
|
||||
<SelectItem value="16x80">16×80mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.specification && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.specification.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.specification && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 규격은 품목명 선택 시 자동으로 필터링됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목코드 (자동생성) */}
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="itemCode-auto">품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
id="itemCode-auto"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
value={(() => {
|
||||
const name = itemName || '';
|
||||
const spec = getValues('specification') || '';
|
||||
return spec ? `${name}-${spec}` : name;
|
||||
})()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 품목 상태 (RM/SM만) */}
|
||||
{(selectedItemType === 'RM' || selectedItemType === 'SM') && (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="isActive">품목 상태</Label>
|
||||
<Select
|
||||
value={materialStatus}
|
||||
onValueChange={(value) => {
|
||||
setMaterialStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="isActive">
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단위 (RM/SM/CS 공통) */}
|
||||
<div>
|
||||
<Label htmlFor="unit">
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedUnit}
|
||||
onValueChange={(value) => {
|
||||
setSelectedUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="unit" className={errors.unit ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="T">T (톤)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="M2">M² (제곱미터)</SelectItem>
|
||||
<SelectItem value="M3">M³ (세제곱미터)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
<SelectItem value="SHEET">SHEET (장)</SelectItem>
|
||||
<SelectItem value="PACK">PACK (팩)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.unit && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.unit.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
273
src/components/items/ItemForm/forms/PartForm.tsx
Normal file
273
src/components/items/ItemForm/forms/PartForm.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 부품 (PT) 폼 컴포넌트
|
||||
* - ASSEMBLY (조립 부품)
|
||||
* - BENDING (절곡 부품)
|
||||
* - PURCHASED (구매 부품)
|
||||
*/
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormClearErrors, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { AssemblyPartForm, BendingPartForm, PurchasedPartForm } from './parts';
|
||||
|
||||
export interface PartFormProps {
|
||||
// Part Type
|
||||
selectedPartType: string;
|
||||
setSelectedPartType: (value: string) => void;
|
||||
// Category
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedInstallationType: string;
|
||||
setSelectedInstallationType: (value: string) => void;
|
||||
// ASSEMBLY
|
||||
sideSpecWidth: string;
|
||||
setSideSpecWidth: (value: string) => void;
|
||||
sideSpecHeight: string;
|
||||
setSideSpecHeight: (value: string) => void;
|
||||
assemblyLength: string;
|
||||
setAssemblyLength: (value: string) => void;
|
||||
assemblyUnit: string;
|
||||
setAssemblyUnit: (value: string) => void;
|
||||
// BENDING
|
||||
selectedBendingItemType: string;
|
||||
setSelectedBendingItemType: (value: string) => void;
|
||||
material: string;
|
||||
setMaterial: (value: string) => void;
|
||||
widthSum: string;
|
||||
setWidthSum: (value: string) => void;
|
||||
bendingLength: string;
|
||||
setBendingLength: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
bendingDetailsLength: number;
|
||||
// PURCHASED
|
||||
electricOpenerPower: string;
|
||||
setElectricOpenerPower: (value: string) => void;
|
||||
electricOpenerCapacity: string;
|
||||
setElectricOpenerCapacity: (value: string) => void;
|
||||
motorVoltage: string;
|
||||
setMotorVoltage: (value: string) => void;
|
||||
chainSpec: string;
|
||||
setChainSpec: (value: string) => void;
|
||||
// Common
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
// Item Code Generator
|
||||
generateItemCode: () => string;
|
||||
// Form
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function PartForm({
|
||||
selectedPartType,
|
||||
setSelectedPartType,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetailsLength,
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
generateItemCode,
|
||||
register,
|
||||
setValue,
|
||||
clearErrors,
|
||||
errors,
|
||||
}: PartFormProps) {
|
||||
// 부품 유형 변경 시 필드 초기화 핸들러
|
||||
const handlePartTypeChange = (value: string) => {
|
||||
setSelectedPartType(value);
|
||||
setValue('partType', value);
|
||||
clearErrors('partType');
|
||||
|
||||
// 공통 필드 초기화
|
||||
setSelectedCategory1('');
|
||||
setValue('category1', undefined);
|
||||
setPartUnit('EA');
|
||||
setValue('unit', 'EA');
|
||||
|
||||
// ASSEMBLY 부품 전용 필드 초기화
|
||||
setSelectedInstallationType('');
|
||||
setValue('installationType', undefined);
|
||||
setSideSpecWidth('');
|
||||
setValue('sideSpecWidth', '');
|
||||
setSideSpecHeight('');
|
||||
setValue('sideSpecHeight', '');
|
||||
setAssemblyLength('');
|
||||
setValue('assemblyLength', '');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// BENDING 부품 전용 필드 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setValue('category2', undefined);
|
||||
setMaterial('');
|
||||
setValue('material', '');
|
||||
setWidthSum('');
|
||||
setValue('length', '');
|
||||
setBendingLength('');
|
||||
setValue('bendingLength', '');
|
||||
|
||||
// PURCHASED 부품 전용 필드 초기화
|
||||
setElectricOpenerPower('');
|
||||
setValue('electricOpenerPower', '');
|
||||
setElectricOpenerCapacity('');
|
||||
setValue('electricOpenerCapacity', '');
|
||||
setMotorVoltage('');
|
||||
setValue('motorVoltage', '');
|
||||
setChainSpec('');
|
||||
setValue('chainSpec', '');
|
||||
|
||||
// BOM 설정 (절곡 부품은 BOM 없음, 조립 부품은 BOM 기본 true)
|
||||
setNeedsBOM(value === 'BENDING' ? false : value === 'ASSEMBLY' ? true : needsBOM);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 부품 유형 선택 - 항상 표시 */}
|
||||
<div>
|
||||
<Label>
|
||||
부품 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPartType}
|
||||
onValueChange={handlePartTypeChange}
|
||||
>
|
||||
<SelectTrigger className={errors.partType ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="부품 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ASSEMBLY">조립 부품 (Assembly Part)</SelectItem>
|
||||
<SelectItem value="BENDING">절곡 부품 (Bending Part) - 전개도만 사용</SelectItem>
|
||||
<SelectItem value="PURCHASED">구매 부품 (Purchased Part)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.partType && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.partType.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.partType && selectedPartType === 'BENDING' && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 절곡 부품은 전개도(바라시)만 있으며, 부품 구성(BOM)은 사용하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASSEMBLY 부품인 경우 */}
|
||||
{selectedPartType === 'ASSEMBLY' && (
|
||||
<AssemblyPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
selectedInstallationType={selectedInstallationType}
|
||||
setSelectedInstallationType={setSelectedInstallationType}
|
||||
sideSpecWidth={sideSpecWidth}
|
||||
setSideSpecWidth={setSideSpecWidth}
|
||||
sideSpecHeight={sideSpecHeight}
|
||||
setSideSpecHeight={setSideSpecHeight}
|
||||
assemblyLength={assemblyLength}
|
||||
setAssemblyLength={setAssemblyLength}
|
||||
assemblyUnit={assemblyUnit}
|
||||
setAssemblyUnit={setAssemblyUnit}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
needsBOM={needsBOM}
|
||||
setNeedsBOM={setNeedsBOM}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BENDING 부품인 경우 */}
|
||||
{selectedPartType === 'BENDING' && (
|
||||
<BendingPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
selectedBendingItemType={selectedBendingItemType}
|
||||
setSelectedBendingItemType={setSelectedBendingItemType}
|
||||
material={material}
|
||||
setMaterial={setMaterial}
|
||||
widthSum={widthSum}
|
||||
setWidthSum={setWidthSum}
|
||||
bendingLength={bendingLength}
|
||||
setBendingLength={setBendingLength}
|
||||
partUnit={partUnit}
|
||||
setPartUnit={setPartUnit}
|
||||
bendingDetailsLength={bendingDetailsLength}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
generateItemCode={generateItemCode}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
clearErrors={clearErrors}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PURCHASED 부품인 경우 */}
|
||||
{selectedPartType === 'PURCHASED' && (
|
||||
<PurchasedPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
electricOpenerPower={electricOpenerPower}
|
||||
setElectricOpenerPower={setElectricOpenerPower}
|
||||
electricOpenerCapacity={electricOpenerCapacity}
|
||||
setElectricOpenerCapacity={setElectricOpenerCapacity}
|
||||
motorVoltage={motorVoltage}
|
||||
setMotorVoltage={setMotorVoltage}
|
||||
chainSpec={chainSpec}
|
||||
setChainSpec={setChainSpec}
|
||||
partUnit={partUnit}
|
||||
setPartUnit={setPartUnit}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
needsBOM={needsBOM}
|
||||
setNeedsBOM={setNeedsBOM}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
337
src/components/items/ItemForm/forms/ProductForm.tsx
Normal file
337
src/components/items/ItemForm/forms/ProductForm.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 제품 (FG) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { X } from 'lucide-react';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface ProductFormProps {
|
||||
productName: string;
|
||||
setProductName: (value: string) => void;
|
||||
productStatus: string;
|
||||
setProductStatus: (value: string) => void;
|
||||
remarks: string;
|
||||
setRemarks: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
specificationFile: File | null;
|
||||
setSpecificationFile: (file: File | null) => void;
|
||||
certificationFile: File | null;
|
||||
setCertificationFile: (file: File | null) => void;
|
||||
isSubmitting: boolean;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
getValues: UseFormGetValues<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function ProductForm({
|
||||
productName,
|
||||
setProductName,
|
||||
productStatus,
|
||||
setProductStatus,
|
||||
remarks,
|
||||
setRemarks,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
errors,
|
||||
}: ProductFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<Label htmlFor="productName">
|
||||
상품명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
placeholder="상품명을 입력하세요 (예: 프리미엄 스크린)"
|
||||
value={productName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setProductName(newName);
|
||||
setValue('productName', newName);
|
||||
}}
|
||||
className={errors.productName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.productName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.productName.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.productName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
상품명을 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="품목명을 입력하세요"
|
||||
{...register('itemName')}
|
||||
className={errors.itemName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.itemName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
품목명을 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={(() => {
|
||||
const pName = productName || '';
|
||||
const iName = getValues('itemName') || '';
|
||||
return pName && iName ? `${pName}-${iName}` : '';
|
||||
})()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="상품명과 품목명을 입력하면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '상품명-품목명' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>로트 약자</Label>
|
||||
<Input
|
||||
placeholder="로트 약자를 입력하세요"
|
||||
{...register('lotAbbreviation')}
|
||||
maxLength={10}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 로트 번호 생성 시 사용되는 약자 (선택사항)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={productStatus}
|
||||
onValueChange={(value) => {
|
||||
setProductStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FG 인정 정보 섹션 컴포넌트
|
||||
*/
|
||||
export function ProductCertificationSection({
|
||||
remarks,
|
||||
setRemarks,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
}: Pick<ProductFormProps,
|
||||
| 'remarks'
|
||||
| 'setRemarks'
|
||||
| 'needsBOM'
|
||||
| 'setNeedsBOM'
|
||||
| 'specificationFile'
|
||||
| 'setSpecificationFile'
|
||||
| 'certificationFile'
|
||||
| 'setCertificationFile'
|
||||
| 'isSubmitting'
|
||||
| 'register'
|
||||
>) {
|
||||
return (
|
||||
<div className="pt-6 mt-6 border-t space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-4">인정 정보</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
제품 인정서 및 시방서를 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationNumber">인정번호</Label>
|
||||
<Input
|
||||
id="certificationNumber"
|
||||
placeholder="인정번호를 입력하세요"
|
||||
{...register('certificationNumber')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationStartDate">인정 유효기간 시작일</Label>
|
||||
<Input
|
||||
id="certificationStartDate"
|
||||
type="date"
|
||||
{...register('certificationStartDate')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationEndDate">인정 유효기간 종료일</Label>
|
||||
<Input
|
||||
id="certificationEndDate"
|
||||
type="date"
|
||||
{...register('certificationEndDate')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 시방서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>시방서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSpecificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{specificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인정서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인정서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCertificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{certificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 추가 설명이나 특이사항을 기록할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 - FG 전용, 인정 정보 카드 내부 */}
|
||||
<div className="md:col-span-2 pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-fg"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-fg" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 제품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/components/items/ItemForm/forms/index.ts
Normal file
7
src/components/items/ItemForm/forms/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 품목 유형별 폼 컴포넌트 export
|
||||
*/
|
||||
|
||||
export { default as MaterialForm } from './MaterialForm';
|
||||
export { default as ProductForm, ProductCertificationSection } from './ProductForm';
|
||||
export { default as PartForm } from './PartForm';
|
||||
336
src/components/items/ItemForm/forms/parts/AssemblyPartForm.tsx
Normal file
336
src/components/items/ItemForm/forms/parts/AssemblyPartForm.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 조립 부품 (ASSEMBLY) 폼 컴포넌트
|
||||
* - 가이드레일, 케이스, 하단마감재
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface AssemblyPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedInstallationType: string;
|
||||
setSelectedInstallationType: (value: string) => void;
|
||||
sideSpecWidth: string;
|
||||
setSideSpecWidth: (value: string) => void;
|
||||
sideSpecHeight: string;
|
||||
setSideSpecHeight: (value: string) => void;
|
||||
assemblyLength: string;
|
||||
setAssemblyLength: (value: string) => void;
|
||||
assemblyUnit: string;
|
||||
setAssemblyUnit: (value: string) => void;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function AssemblyPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
}: AssemblyPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory1(value);
|
||||
setValue('category1', value);
|
||||
if (value === 'guide_rail') setValue('itemName', '가이드레일');
|
||||
else if (value === 'case') setValue('itemName', '케이스');
|
||||
else if (value === 'bottom_finish') setValue('itemName', '하단마감재');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.category1 ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="guide_rail">가이드레일</SelectItem>
|
||||
<SelectItem value="case">케이스</SelectItem>
|
||||
<SelectItem value="bottom_finish">하단마감재</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.category1 && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.category1.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 가이드레일: 설치 유형 */}
|
||||
{selectedCategory1 === 'guide_rail' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', value === 'wall' ? 'R' : 'S');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wall">벽면형 (R)</SelectItem>
|
||||
<SelectItem value="side">측면형 (S)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 케이스: 설치 유형 */}
|
||||
{selectedCategory1 === 'case' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', 'C');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">표준형 (C)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단마감재: 설치 유형 */}
|
||||
{selectedCategory1 === 'bottom_finish' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', value === 'steel' ? 'B' : 'T');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="steel">스크린 (B)</SelectItem>
|
||||
<SelectItem value="iron">철재 (T)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ASSEMBLY 공통: 단위, 비고, 측면규격 및 길이 */}
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={assemblyUnit}
|
||||
onValueChange={(value) => {
|
||||
setAssemblyUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 측면 규격 및 길이 */}
|
||||
<div className="col-span-2 border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">측면 규격 및 길이</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>
|
||||
측면 규격 (가로) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="예: 50"
|
||||
value={sideSpecWidth}
|
||||
onChange={(e) => {
|
||||
setSideSpecWidth(e.target.value);
|
||||
setValue('sideSpecWidth', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
측면 규격 (세로) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="예: 100"
|
||||
value={sideSpecHeight}
|
||||
onChange={(e) => {
|
||||
setSideSpecHeight(e.target.value);
|
||||
setValue('sideSpecHeight', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
길이 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={assemblyLength}
|
||||
onValueChange={(value) => {
|
||||
setAssemblyLength(value);
|
||||
setValue('assemblyLength', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="길이를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1219">1219mm</SelectItem>
|
||||
<SelectItem value="2438">2438mm</SelectItem>
|
||||
<SelectItem value="3000">3000mm</SelectItem>
|
||||
<SelectItem value="3500">3500mm</SelectItem>
|
||||
<SelectItem value="4000">4000mm</SelectItem>
|
||||
<SelectItem value="4150">4150mm</SelectItem>
|
||||
<SelectItem value="4200">4200mm</SelectItem>
|
||||
<SelectItem value="4300">4300mm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* 품목코드: {(() => {
|
||||
const itemName = selectedCategory1 === 'guide_rail' ? '가이드레일' :
|
||||
selectedCategory1 === 'case' ? '케이스' :
|
||||
selectedCategory1 === 'bottom_finish' ? '하단마감재' : '';
|
||||
const installationTypeMap: Record<string, string> = {
|
||||
"standard": "표준형",
|
||||
"wall": "벽면형",
|
||||
"side": "측면형",
|
||||
"steel": "스크린",
|
||||
"iron": "철재"
|
||||
};
|
||||
const installTypeText = installationTypeMap[selectedInstallationType] || selectedInstallationType;
|
||||
const length = assemblyLength ? parseInt(assemblyLength) : 0;
|
||||
let lengthCode = "";
|
||||
if (length === 1219) lengthCode = "12";
|
||||
else if (length === 2438) lengthCode = "24";
|
||||
else if (length === 3000) lengthCode = "30";
|
||||
else if (length === 3500) lengthCode = "35";
|
||||
else if (length === 4000) lengthCode = "40";
|
||||
else if (length === 4150) lengthCode = "41";
|
||||
else if (length === 4200) lengthCode = "42";
|
||||
else if (length === 4300) lengthCode = "43";
|
||||
else lengthCode = Math.floor(length / 100).toString().padStart(2, '0');
|
||||
|
||||
if (itemName && installTypeText && sideSpecWidth && sideSpecHeight && assemblyLength) {
|
||||
return `${itemName} ${installTypeText}-${sideSpecWidth}*${sideSpecHeight}*${lengthCode}`;
|
||||
}
|
||||
return "품목명 설치유형-?*?*?";
|
||||
})()}
|
||||
</p>
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="mt-4">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 - ASSEMBLY 전용 */}
|
||||
{selectedCategory1 && (
|
||||
<div className="pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-assembly"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-assembly" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 부품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
src/components/items/ItemForm/forms/parts/BendingPartForm.tsx
Normal file
302
src/components/items/ItemForm/forms/parts/BendingPartForm.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 절곡 부품 (BENDING) 폼 컴포넌트
|
||||
* - 가이드레일(벽면/측면), 케이스, 하단마감재 등
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormClearErrors, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES, PART_ITEM_NAMES } from '../../constants';
|
||||
|
||||
export interface BendingPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedBendingItemType: string;
|
||||
setSelectedBendingItemType: (value: string) => void;
|
||||
material: string;
|
||||
setMaterial: (value: string) => void;
|
||||
widthSum: string;
|
||||
setWidthSum: (value: string) => void;
|
||||
bendingLength: string;
|
||||
setBendingLength: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
bendingDetailsLength: number;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
generateItemCode: () => string;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function BendingPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetailsLength,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
generateItemCode,
|
||||
register,
|
||||
setValue,
|
||||
clearErrors,
|
||||
errors,
|
||||
}: BendingPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCategory1(val);
|
||||
setValue('category1', val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_TYPE_CATEGORIES.BENDING?.categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label} ({cat.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 종류 선택 */}
|
||||
{selectedCategory1 && PART_ITEM_NAMES[selectedCategory1] && (
|
||||
<div>
|
||||
<Label>
|
||||
종류 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedBendingItemType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedBendingItemType(value);
|
||||
const selected = PART_ITEM_NAMES[selectedCategory1].find(item => item.label === value);
|
||||
if (selected) {
|
||||
setValue('itemName', selected.label);
|
||||
setValue('category2', selected.code);
|
||||
clearErrors('category2');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="종류를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_ITEM_NAMES[selectedCategory1].map((item) => (
|
||||
<SelectItem key={item.value} value={item.label}>
|
||||
{item.label} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 재질, 폭 합계, 모양&길이 (Purple Section) */}
|
||||
{selectedBendingItemType && (
|
||||
<div className="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div>
|
||||
<Label>
|
||||
재질 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={material}
|
||||
onValueChange={(value) => {
|
||||
setMaterial(value);
|
||||
setValue('material', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.material ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="재질을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EGI 1.15T">EGI 1.15T</SelectItem>
|
||||
<SelectItem value="EGI 1.55T">EGI 1.55T</SelectItem>
|
||||
<SelectItem value="SUS 1.2T">SUS 1.2T</SelectItem>
|
||||
<SelectItem value="SUS 1.5T">SUS 1.5T</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.material && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.material.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
폭 합계 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={widthSum}
|
||||
onChange={(e) => {
|
||||
setWidthSum(e.target.value);
|
||||
setValue('length', e.target.value);
|
||||
}}
|
||||
placeholder="전개도 상세를 입력해주세요"
|
||||
readOnly={bendingDetailsLength > 0}
|
||||
className={`${bendingDetailsLength > 0 ? "bg-blue-50 font-medium" : ""} ${errors.length ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">mm</span>
|
||||
</div>
|
||||
{errors.length && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.length.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.length && bendingDetailsLength > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
* 전개도 상세 입력의 합계가 자동 반영됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
모양&길이 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={bendingLength}
|
||||
onValueChange={(value) => {
|
||||
setBendingLength(value);
|
||||
setValue('bendingLength', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.bendingLength ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="모양&길이를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="W50x3000">W50×3000mm</SelectItem>
|
||||
<SelectItem value="W50x4000">W50×4000mm</SelectItem>
|
||||
<SelectItem value="W80x3000">W80×3000mm</SelectItem>
|
||||
<SelectItem value="W80x4000">W80×4000mm</SelectItem>
|
||||
<SelectItem value="1219">1219mm</SelectItem>
|
||||
<SelectItem value="2438">2438mm</SelectItem>
|
||||
<SelectItem value="3000">3000mm</SelectItem>
|
||||
<SelectItem value="3500">3500mm</SelectItem>
|
||||
<SelectItem value="4000">4000mm</SelectItem>
|
||||
<SelectItem value="4150">4150mm</SelectItem>
|
||||
<SelectItem value="4200">4200mm</SelectItem>
|
||||
<SelectItem value="4300">4300mm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.bendingLength && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.bendingLength.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단위, 비고 (종류 선택 후 표시) */}
|
||||
{selectedBendingItemType && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={partUnit}
|
||||
onValueChange={(value) => {
|
||||
setPartUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={generateItemCode()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{(selectedCategory1 === "guide_rail_wall" || selectedCategory1 === "guide_rail_side")
|
||||
? "* 가이드레일 품목코드는 '제품구분(R/S)+종류(M/T/C/D/S/U)+모양&길이' 형식으로 자동 생성됩니다 (예: RD30, SM53)"
|
||||
: "* 절곡 부품 품목코드는 '품목명+종류+길이축약' 형식으로 자동 생성됩니다 (예: 케이스후면부30)"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
318
src/components/items/ItemForm/forms/parts/PurchasedPartForm.tsx
Normal file
318
src/components/items/ItemForm/forms/parts/PurchasedPartForm.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 구매 부품 (PURCHASED) 폼 컴포넌트
|
||||
* - 전동개폐기, 모터, 체인 등
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES } from '../../constants';
|
||||
|
||||
export interface PurchasedPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
electricOpenerPower: string;
|
||||
setElectricOpenerPower: (value: string) => void;
|
||||
electricOpenerCapacity: string;
|
||||
setElectricOpenerCapacity: (value: string) => void;
|
||||
motorVoltage: string;
|
||||
setMotorVoltage: (value: string) => void;
|
||||
chainSpec: string;
|
||||
setChainSpec: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function PurchasedPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
}: PurchasedPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCategory1(val);
|
||||
setValue('category1', val);
|
||||
const cat = PART_TYPE_CATEGORIES.PURCHASED?.categories.find(c => c.value === val);
|
||||
if (cat) {
|
||||
setValue('category2', cat.code);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_TYPE_CATEGORIES.PURCHASED?.categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label} ({cat.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전동개폐기 전용 필드 */}
|
||||
{selectedCategory1 === 'electric_opener' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
전원 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={electricOpenerPower}
|
||||
onValueChange={(value) => {
|
||||
setElectricOpenerPower(value);
|
||||
setValue('electricOpenerPower', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.electricOpenerPower ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="전원을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="220V">220V</SelectItem>
|
||||
<SelectItem value="380V">380V</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.electricOpenerPower && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.electricOpenerPower.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
용량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={electricOpenerCapacity}
|
||||
onValueChange={(value) => {
|
||||
setElectricOpenerCapacity(value);
|
||||
setValue('electricOpenerCapacity', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.electricOpenerCapacity ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="용량을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="150">150 KG</SelectItem>
|
||||
<SelectItem value="300">300 KG</SelectItem>
|
||||
<SelectItem value="400">400 KG</SelectItem>
|
||||
<SelectItem value="500">500 KG</SelectItem>
|
||||
<SelectItem value="600">600 KG</SelectItem>
|
||||
<SelectItem value="800">800 KG</SelectItem>
|
||||
<SelectItem value="1000">1000 KG</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.electricOpenerCapacity && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.electricOpenerCapacity.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 모터 전용 필드 */}
|
||||
{selectedCategory1 === 'motor' && (
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div>
|
||||
<Label>모터 용량 (kg) *</Label>
|
||||
<Input type="number" placeholder="예: 1.5" step="0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>전압 (V) *</Label>
|
||||
<Select
|
||||
value={motorVoltage}
|
||||
onValueChange={(value) => {
|
||||
setMotorVoltage(value);
|
||||
setValue('motorVoltage', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.motorVoltage ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="전압을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="220">220V</SelectItem>
|
||||
<SelectItem value="380">380V</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.motorVoltage && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.motorVoltage.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 체인 전용 필드 */}
|
||||
{selectedCategory1 === 'chain' && (
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div>
|
||||
<Label>체인 규격 *</Label>
|
||||
<Select
|
||||
value={chainSpec}
|
||||
onValueChange={(value) => {
|
||||
setChainSpec(value);
|
||||
setValue('chainSpec', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.chainSpec ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="규격을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="40">40</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="60">60</SelectItem>
|
||||
<SelectItem value="80">80</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.chainSpec && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.chainSpec.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>길이 (링크 수) *</Label>
|
||||
<Input type="number" placeholder="예: 100" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목명 선택 후에만 단위, 비고 표시 */}
|
||||
{selectedCategory1 && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={partUnit}
|
||||
onValueChange={(value) => {
|
||||
setPartUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value=""
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 */}
|
||||
<div className="md:col-span-2 pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-purchased"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-purchased" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 부품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/components/items/ItemForm/forms/parts/index.ts
Normal file
12
src/components/items/ItemForm/forms/parts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 부품 유형별 폼 컴포넌트 export
|
||||
*/
|
||||
|
||||
export { default as AssemblyPartForm } from './AssemblyPartForm';
|
||||
export type { AssemblyPartFormProps } from './AssemblyPartForm';
|
||||
|
||||
export { default as BendingPartForm } from './BendingPartForm';
|
||||
export type { BendingPartFormProps } from './BendingPartForm';
|
||||
|
||||
export { default as PurchasedPartForm } from './PurchasedPartForm';
|
||||
export type { PurchasedPartFormProps } from './PurchasedPartForm';
|
||||
12
src/components/items/ItemForm/hooks/index.ts
Normal file
12
src/components/items/ItemForm/hooks/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 품목 폼 커스텀 훅 export
|
||||
*/
|
||||
|
||||
export { useItemFormState } from './useItemFormState';
|
||||
export type { UseItemFormStateProps, ItemFormState, UseItemFormStateReturn } from './useItemFormState';
|
||||
|
||||
export { useBOMManagement } from './useBOMManagement';
|
||||
export type { BOMSearchState, UseBOMManagementProps, UseBOMManagementReturn } from './useBOMManagement';
|
||||
|
||||
export { useBendingDetails } from './useBendingDetails';
|
||||
export type { UseBendingDetailsProps, UseBendingDetailsReturn } from './useBendingDetails';
|
||||
221
src/components/items/ItemForm/hooks/useBOMManagement.ts
Normal file
221
src/components/items/ItemForm/hooks/useBOMManagement.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* BOM 관리 커스텀 훅
|
||||
*
|
||||
* BOM 라인 추가, 수정, 삭제 및 검색 상태 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { BOMLine } from '@/types/item';
|
||||
|
||||
export interface BOMSearchState {
|
||||
searchValue: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface UseBOMManagementProps {
|
||||
initialBomLines?: BOMLine[];
|
||||
}
|
||||
|
||||
export function useBOMManagement({ initialBomLines = [] }: UseBOMManagementProps = {}) {
|
||||
const [bomLines, setBomLines] = useState<BOMLine[]>(initialBomLines);
|
||||
const [bomSearchStates, setBomSearchStates] = useState<Record<string, BOMSearchState>>({});
|
||||
|
||||
/**
|
||||
* 새 BOM 라인 추가
|
||||
*/
|
||||
const addBomLine = useCallback(() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines((prev) => [...prev, newLine]);
|
||||
return newLine;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 삭제
|
||||
*/
|
||||
const removeBomLine = useCallback((lineId: string) => {
|
||||
setBomLines((prev) => prev.filter((line) => line.id !== lineId));
|
||||
// 검색 상태도 제거
|
||||
setBomSearchStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
delete newStates[lineId];
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 업데이트
|
||||
*/
|
||||
const updateBomLine = useCallback((lineId: string, updates: Partial<BOMLine>) => {
|
||||
setBomLines((prev) =>
|
||||
prev.map((line) =>
|
||||
line.id === lineId ? { ...line, ...updates } : line
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 품목 선택 시 BOM 라인 데이터 채우기
|
||||
*/
|
||||
const selectItemForBomLine = useCallback((
|
||||
lineId: string,
|
||||
item: {
|
||||
itemCode?: string;
|
||||
itemName?: string;
|
||||
specification?: string;
|
||||
material?: string;
|
||||
unit?: string;
|
||||
partType?: string;
|
||||
bendingDiagram?: string;
|
||||
}
|
||||
) => {
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines((prev) =>
|
||||
prev.map((line) =>
|
||||
line.id === lineId
|
||||
? {
|
||||
...line,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0, // TODO: pricing에서 가져오기
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart ? item.bendingDiagram : undefined,
|
||||
}
|
||||
: line
|
||||
)
|
||||
);
|
||||
|
||||
// 검색 팝오버 닫기
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { searchValue: '', isOpen: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 상태 업데이트
|
||||
*/
|
||||
const updateSearchState = useCallback((lineId: string, updates: Partial<BOMSearchState>) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { ...(prev[lineId] || { searchValue: '', isOpen: false }), ...updates },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 팝오버 열기
|
||||
*/
|
||||
const openSearch = useCallback((lineId: string) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { searchValue: '', isOpen: true },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 팝오버 닫기
|
||||
*/
|
||||
const closeSearch = useCallback((lineId: string) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { ...(prev[lineId] || { searchValue: '' }), isOpen: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 모든 BOM 라인 초기화
|
||||
*/
|
||||
const resetBomLines = useCallback(() => {
|
||||
setBomLines([]);
|
||||
setBomSearchStates({});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 수량 변경
|
||||
*/
|
||||
const updateQuantity = useCallback((lineId: string, quantity: number) => {
|
||||
updateBomLine(lineId, { quantity });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 단가 변경
|
||||
*/
|
||||
const updateUnitPrice = useCallback((lineId: string, unitPrice: number) => {
|
||||
updateBomLine(lineId, { unitPrice });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 재질 변경
|
||||
*/
|
||||
const updateMaterial = useCallback((lineId: string, material: string) => {
|
||||
updateBomLine(lineId, { material });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 비고 변경
|
||||
*/
|
||||
const updateNote = useCallback((lineId: string, note: string) => {
|
||||
updateBomLine(lineId, { note });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* 특정 라인의 검색 상태 가져오기
|
||||
*/
|
||||
const getSearchState = useCallback((lineId: string): BOMSearchState => {
|
||||
return bomSearchStates[lineId] || { searchValue: '', isOpen: false };
|
||||
}, [bomSearchStates]);
|
||||
|
||||
/**
|
||||
* BOM 합계 계산
|
||||
*/
|
||||
const calculateTotal = useCallback(() => {
|
||||
return bomLines.reduce((total, line) => {
|
||||
return total + (line.quantity * (line.unitPrice || 0));
|
||||
}, 0);
|
||||
}, [bomLines]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
|
||||
// BOM 라인 조작
|
||||
addBomLine,
|
||||
removeBomLine,
|
||||
updateBomLine,
|
||||
selectItemForBomLine,
|
||||
|
||||
// 필드 업데이트 헬퍼
|
||||
updateQuantity,
|
||||
updateUnitPrice,
|
||||
updateMaterial,
|
||||
updateNote,
|
||||
|
||||
// 검색 상태 관리
|
||||
updateSearchState,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
getSearchState,
|
||||
|
||||
// 유틸리티
|
||||
resetBomLines,
|
||||
calculateTotal,
|
||||
|
||||
// 상태 체크
|
||||
hasBomLines: bomLines.length > 0,
|
||||
bomLinesCount: bomLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBOMManagementReturn = ReturnType<typeof useBOMManagement>;
|
||||
182
src/components/items/ItemForm/hooks/useBendingDetails.ts
Normal file
182
src/components/items/ItemForm/hooks/useBendingDetails.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 전개도 상세 관리 커스텀 훅
|
||||
*
|
||||
* BENDING 부품의 전개도 치수 계산 및 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface UseBendingDetailsProps {
|
||||
initialDetails?: BendingDetail[];
|
||||
setValue?: UseFormSetValue<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export function useBendingDetails({ initialDetails = [], setValue }: UseBendingDetailsProps = {}) {
|
||||
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>(initialDetails);
|
||||
const [widthSum, setWidthSum] = useState<string>('');
|
||||
|
||||
/**
|
||||
* 폭 합계 계산
|
||||
*/
|
||||
const calculateWidthSum = useCallback((details: BendingDetail[]): number => {
|
||||
return details.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 폭 합계 업데이트 (상태 + form setValue)
|
||||
*/
|
||||
const updateWidthSum = useCallback((details: BendingDetail[]) => {
|
||||
const totalSum = calculateWidthSum(details);
|
||||
const sumStr = totalSum.toFixed(1);
|
||||
setWidthSum(sumStr);
|
||||
setValue?.('length', sumStr);
|
||||
}, [calculateWidthSum, setValue]);
|
||||
|
||||
/**
|
||||
* bendingDetails 변경 시 자동 폭 합계 업데이트
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (bendingDetails.length > 0) {
|
||||
updateWidthSum(bendingDetails);
|
||||
}
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 새 행 추가
|
||||
*/
|
||||
const addDetail = useCallback(() => {
|
||||
const newId = `detail-${Date.now()}`;
|
||||
const newRow: BendingDetail = {
|
||||
id: newId,
|
||||
no: bendingDetails.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
aAngle: undefined,
|
||||
};
|
||||
const newDetails = [...bendingDetails, newRow];
|
||||
setBendingDetails(newDetails);
|
||||
return newRow;
|
||||
}, [bendingDetails]);
|
||||
|
||||
/**
|
||||
* 행 삭제
|
||||
*/
|
||||
const removeDetail = useCallback((index: number) => {
|
||||
const newDetails = bendingDetails
|
||||
.filter((_, i) => i !== index)
|
||||
.map((d, i) => ({ ...d, no: i + 1 })); // 번호 재정렬
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 행 업데이트
|
||||
*/
|
||||
const updateDetail = useCallback((index: number, updates: Partial<BendingDetail>) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = { ...newDetails[index], ...updates };
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 입력값 변경
|
||||
*/
|
||||
const updateInput = useCallback((index: number, value: number) => {
|
||||
updateDetail(index, { input: isNaN(value) ? 0 : value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 연신율 변경
|
||||
*/
|
||||
const updateElongation = useCallback((index: number, value: number) => {
|
||||
updateDetail(index, { elongation: isNaN(value) ? -1 : value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 음영 토글
|
||||
*/
|
||||
const toggleShaded = useCallback((index: number) => {
|
||||
const detail = bendingDetails[index];
|
||||
if (detail) {
|
||||
updateDetail(index, { shaded: !detail.shaded });
|
||||
}
|
||||
}, [bendingDetails, updateDetail]);
|
||||
|
||||
/**
|
||||
* A각 변경
|
||||
*/
|
||||
const updateAAngle = useCallback((index: number, value: number | undefined) => {
|
||||
updateDetail(index, { aAngle: value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 특정 행의 계산값 가져오기
|
||||
*/
|
||||
const getCalculatedValue = useCallback((index: number): number => {
|
||||
const detail = bendingDetails[index];
|
||||
if (!detail) return 0;
|
||||
return detail.input + detail.elongation;
|
||||
}, [bendingDetails]);
|
||||
|
||||
/**
|
||||
* 모든 상세 초기화
|
||||
*/
|
||||
const resetDetails = useCallback(() => {
|
||||
setBendingDetails([]);
|
||||
setWidthSum('');
|
||||
setValue?.('length', '');
|
||||
}, [setValue]);
|
||||
|
||||
/**
|
||||
* 상세 데이터 설정 (외부에서 초기화 시)
|
||||
*/
|
||||
const setDetails = useCallback((details: BendingDetail[]) => {
|
||||
setBendingDetails(details);
|
||||
if (details.length > 0) {
|
||||
updateWidthSum(details);
|
||||
}
|
||||
}, [updateWidthSum]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
bendingDetails,
|
||||
setBendingDetails: setDetails,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
|
||||
// 행 조작
|
||||
addDetail,
|
||||
removeDetail,
|
||||
updateDetail,
|
||||
|
||||
// 필드 업데이트
|
||||
updateInput,
|
||||
updateElongation,
|
||||
toggleShaded,
|
||||
updateAAngle,
|
||||
|
||||
// 계산
|
||||
calculateWidthSum,
|
||||
getCalculatedValue,
|
||||
updateWidthSum,
|
||||
|
||||
// 유틸리티
|
||||
resetDetails,
|
||||
|
||||
// 상태 체크
|
||||
hasDetails: bendingDetails.length > 0,
|
||||
detailsCount: bendingDetails.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBendingDetailsReturn = ReturnType<typeof useBendingDetails>;
|
||||
364
src/components/items/ItemForm/hooks/useItemFormState.ts
Normal file
364
src/components/items/ItemForm/hooks/useItemFormState.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 품목 폼 상태 관리 커스텀 훅
|
||||
*
|
||||
* 25개 이상의 useState를 통합 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ItemMaster, ItemType, BendingDetail, BOMLine } from '@/types/item';
|
||||
import type { UseFormSetValue, UseFormClearErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface UseItemFormStateProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: Partial<ItemMaster>;
|
||||
}
|
||||
|
||||
export interface ItemFormState {
|
||||
// 기본 상태
|
||||
isSubmitting: boolean;
|
||||
selectedItemType: ItemType | '';
|
||||
|
||||
// 파일 상태
|
||||
specificationFile: File | null;
|
||||
certificationFile: File | null;
|
||||
bendingDiagramFile: File | null;
|
||||
bendingDiagram: string;
|
||||
bendingDiagramInputMethod: 'file' | 'drawing';
|
||||
isDrawingOpen: boolean;
|
||||
|
||||
// FG(제품) 상태
|
||||
productName: string;
|
||||
productStatus: string;
|
||||
|
||||
// PT(부품) 상태
|
||||
selectedPartType: string;
|
||||
partStatus: string;
|
||||
|
||||
// SM/RM/CS 상태
|
||||
itemName: string;
|
||||
selectedCategory1: string;
|
||||
selectedInstallationType: string;
|
||||
materialStatus: string;
|
||||
selectedSpecification: string;
|
||||
selectedUnit: string;
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
sideSpecWidth: string;
|
||||
sideSpecHeight: string;
|
||||
assemblyLength: string;
|
||||
assemblyUnit: string;
|
||||
|
||||
// 전동개폐기 상태
|
||||
electricOpenerPower: string;
|
||||
electricOpenerCapacity: string;
|
||||
|
||||
// 모터/체인 상태
|
||||
motorVoltage: string;
|
||||
chainSpec: string;
|
||||
|
||||
// BENDING 부품 상태
|
||||
selectedBendingItemType: string;
|
||||
material: string;
|
||||
bendingLength: string;
|
||||
widthSum: string;
|
||||
partUnit: string;
|
||||
bendingDetails: BendingDetail[];
|
||||
|
||||
// BOM 상태
|
||||
bomLines: BOMLine[];
|
||||
bomSearchStates: Record<string, { searchValue: string; isOpen: boolean }>;
|
||||
needsBOM: boolean;
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
export function useItemFormState({ mode, initialData }: UseItemFormStateProps) {
|
||||
// 기본 상태
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedItemType, setSelectedItemType] = useState<ItemType | ''>(
|
||||
mode === 'edit' ? (initialData?.itemType || 'FG') : ''
|
||||
);
|
||||
|
||||
// BOM 상태
|
||||
const [bomLines, setBomLines] = useState<BOMLine[]>(initialData?.bom || []);
|
||||
const [bomSearchStates, setBomSearchStates] = useState<Record<string, { searchValue: string; isOpen: boolean }>>({});
|
||||
|
||||
// 파일 상태
|
||||
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
|
||||
const [certificationFile, setCertificationFile] = useState<File | null>(null);
|
||||
const [bendingDiagramFile, setBendingDiagramFile] = useState<File | null>(null);
|
||||
const [bendingDiagram, setBendingDiagram] = useState<string>(initialData?.bendingDiagram || '');
|
||||
const [bendingDiagramInputMethod, setBendingDiagramInputMethod] = useState<'file' | 'drawing'>('file');
|
||||
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
|
||||
|
||||
// FG(제품) 상태
|
||||
const [productName, setProductName] = useState<string>(initialData?.itemName || '');
|
||||
const [productStatus, setProductStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
|
||||
// PT(부품) 상태
|
||||
const [selectedPartType, setSelectedPartType] = useState<string>(initialData?.partType || '');
|
||||
const [partStatus, setPartStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
|
||||
// SM/RM/CS 상태
|
||||
const [itemName, setItemName] = useState<string>(initialData?.itemName || '');
|
||||
const [selectedCategory1, setSelectedCategory1] = useState<string>(initialData?.category1 || '');
|
||||
const [selectedInstallationType, setSelectedInstallationType] = useState<string>(
|
||||
initialData?.installationType || ''
|
||||
);
|
||||
const [materialStatus, setMaterialStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
const [selectedSpecification, setSelectedSpecification] = useState<string>(initialData?.specification || '');
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(initialData?.unit || '');
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
const [sideSpecWidth, setSideSpecWidth] = useState<string>(initialData?.sideSpecWidth || '');
|
||||
const [sideSpecHeight, setSideSpecHeight] = useState<string>(initialData?.sideSpecHeight || '');
|
||||
const [assemblyLength, setAssemblyLength] = useState<string>(initialData?.assemblyLength || '');
|
||||
const [assemblyUnit, setAssemblyUnit] = useState<string>(initialData?.unit || 'EA');
|
||||
|
||||
// 전동개폐기 상태
|
||||
const [electricOpenerPower, setElectricOpenerPower] = useState<string>('');
|
||||
const [electricOpenerCapacity, setElectricOpenerCapacity] = useState<string>('');
|
||||
|
||||
// 모터/체인 상태
|
||||
const [motorVoltage, setMotorVoltage] = useState<string>('');
|
||||
const [chainSpec, setChainSpec] = useState<string>('');
|
||||
|
||||
// BENDING 부품 상태
|
||||
const [selectedBendingItemType, setSelectedBendingItemType] = useState<string>('');
|
||||
const [material, setMaterial] = useState<string>(initialData?.material || '');
|
||||
const [bendingLength, setBendingLength] = useState<string>(initialData?.bendingLength || '');
|
||||
const [widthSum, setWidthSum] = useState<string>(initialData?.length || '');
|
||||
const [partUnit, setPartUnit] = useState<string>(initialData?.unit || 'EA');
|
||||
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>(
|
||||
initialData?.bendingDetails || []
|
||||
);
|
||||
|
||||
// BOM 필요 여부
|
||||
const [needsBOM, setNeedsBOM] = useState<boolean>(false);
|
||||
|
||||
// 비고 (FG 전용)
|
||||
const [remarks, setRemarks] = useState<string>(initialData?.note || '');
|
||||
|
||||
// 품목 유형 변경 시 모든 상태 초기화
|
||||
const resetAllStates = useCallback((
|
||||
setValue: UseFormSetValue<CreateItemFormData>,
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>,
|
||||
type: ItemType
|
||||
) => {
|
||||
// FG(제품) 상태 초기화
|
||||
setProductName('');
|
||||
setProductStatus('true');
|
||||
|
||||
// PT(부품) 상태 초기화
|
||||
setSelectedPartType('');
|
||||
setPartStatus('true');
|
||||
|
||||
// SM/RM/CS 상태 초기화
|
||||
setItemName('');
|
||||
setSelectedCategory1('');
|
||||
setSelectedInstallationType('');
|
||||
setMaterialStatus('true');
|
||||
setSelectedSpecification('');
|
||||
setSelectedUnit('');
|
||||
|
||||
// ASSEMBLY 부품 상태 초기화
|
||||
setSideSpecWidth('');
|
||||
setSideSpecHeight('');
|
||||
setAssemblyLength('');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// 전동개폐기 상태 초기화
|
||||
setElectricOpenerPower('');
|
||||
setElectricOpenerCapacity('');
|
||||
|
||||
// 모터/체인 상태 초기화
|
||||
setMotorVoltage('');
|
||||
setChainSpec('');
|
||||
|
||||
// BENDING 부품 상태 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setMaterial('');
|
||||
setBendingLength('');
|
||||
setWidthSum('');
|
||||
setPartUnit('EA');
|
||||
setBendingDetails([]);
|
||||
|
||||
// BOM 및 파일 초기화
|
||||
setNeedsBOM(false);
|
||||
setBomLines([]);
|
||||
setSpecificationFile(null);
|
||||
setCertificationFile(null);
|
||||
setBendingDiagramFile(null);
|
||||
setBendingDiagram('');
|
||||
|
||||
// react-hook-form 필드 초기화
|
||||
setValue('itemCode', '');
|
||||
setValue('itemName', '');
|
||||
setValue('unit', (type === 'SM' || type === 'RM' || type === 'CS') ? '' : 'EA');
|
||||
setValue('specification', '');
|
||||
setValue('purchasePrice', 0);
|
||||
setValue('salesPrice', 0);
|
||||
setValue('processingCost', 0);
|
||||
setValue('laborCost', 0);
|
||||
setValue('installCost', 0);
|
||||
setValue('isActive', true);
|
||||
|
||||
// 검증 에러 초기화
|
||||
clearErrors();
|
||||
}, []);
|
||||
|
||||
// 부품 유형 변경 시 부품 관련 상태만 초기화
|
||||
const resetPartStates = useCallback((
|
||||
setValue: UseFormSetValue<CreateItemFormData>
|
||||
) => {
|
||||
// 공통 필드 초기화
|
||||
setSelectedCategory1('');
|
||||
setValue('category1', undefined);
|
||||
setPartUnit('EA');
|
||||
setValue('unit', 'EA');
|
||||
|
||||
// ASSEMBLY 부품 전용 필드 초기화
|
||||
setSelectedInstallationType('');
|
||||
setValue('installationType', undefined);
|
||||
setSideSpecWidth('');
|
||||
setValue('sideSpecWidth', '');
|
||||
setSideSpecHeight('');
|
||||
setValue('sideSpecHeight', '');
|
||||
setAssemblyLength('');
|
||||
setValue('assemblyLength', '');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// BENDING 부품 전용 필드 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setValue('category2', undefined);
|
||||
setMaterial('');
|
||||
setValue('material', '');
|
||||
setWidthSum('');
|
||||
setValue('length', '');
|
||||
setBendingLength('');
|
||||
setValue('bendingLength', '');
|
||||
setBendingDetails([]);
|
||||
|
||||
// PURCHASED 부품 전용 필드 초기화
|
||||
setElectricOpenerPower('');
|
||||
setValue('electricOpenerPower', '');
|
||||
setElectricOpenerCapacity('');
|
||||
setValue('electricOpenerCapacity', '');
|
||||
setMotorVoltage('');
|
||||
setValue('motorVoltage', '');
|
||||
setChainSpec('');
|
||||
setValue('chainSpec', '');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 기본 상태
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
selectedItemType,
|
||||
setSelectedItemType,
|
||||
|
||||
// BOM 상태
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
|
||||
// 파일 상태
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
bendingDiagramFile,
|
||||
setBendingDiagramFile,
|
||||
bendingDiagram,
|
||||
setBendingDiagram,
|
||||
bendingDiagramInputMethod,
|
||||
setBendingDiagramInputMethod,
|
||||
isDrawingOpen,
|
||||
setIsDrawingOpen,
|
||||
|
||||
// FG(제품) 상태
|
||||
productName,
|
||||
setProductName,
|
||||
productStatus,
|
||||
setProductStatus,
|
||||
|
||||
// PT(부품) 상태
|
||||
selectedPartType,
|
||||
setSelectedPartType,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
|
||||
// SM/RM/CS 상태
|
||||
itemName,
|
||||
setItemName,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
materialStatus,
|
||||
setMaterialStatus,
|
||||
selectedSpecification,
|
||||
setSelectedSpecification,
|
||||
selectedUnit,
|
||||
setSelectedUnit,
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
|
||||
// 전동개폐기 상태
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
|
||||
// 모터/체인 상태
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
|
||||
// BENDING 부품 상태
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetails,
|
||||
setBendingDetails,
|
||||
|
||||
// BOM 필요 여부
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
|
||||
// 비고
|
||||
remarks,
|
||||
setRemarks,
|
||||
|
||||
// 헬퍼 함수
|
||||
resetAllStates,
|
||||
resetPartStates,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseItemFormStateReturn = ReturnType<typeof useItemFormState>;
|
||||
1608
src/components/items/ItemForm/index.tsx
Normal file
1608
src/components/items/ItemForm/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
21
src/components/items/ItemForm/types.ts
Normal file
21
src/components/items/ItemForm/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ItemForm 타입 정의
|
||||
*/
|
||||
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface ItemFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: ItemMaster;
|
||||
onSubmit: (data: CreateItemFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
// BOM 검색 상태 타입
|
||||
export interface BOMSearchState {
|
||||
searchValue: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
// 전개도 입력 방식
|
||||
export type BendingDiagramInputMethod = 'file' | 'drawing';
|
||||
Reference in New Issue
Block a user