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:
byeongcheolryu
2025-11-27 22:19:50 +09:00
parent b73603822b
commit 65a8510c0b
130 changed files with 11031 additions and 2287 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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': '단위',
};

View 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;

View File

@@ -0,0 +1,12 @@
/**
* 품목 폼 컨텍스트 export
*/
export {
ItemFormProvider,
useItemFormContext,
useOptionalItemFormContext,
default as ItemFormContext,
} from './ItemFormContext';
export type { ItemFormContextType, ItemFormProviderProps } from './ItemFormContext';

View 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>
</>
);
}

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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';

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
)}
</>
);
}

View 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';

View 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';

View 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>;

View 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>;

View 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>;

File diff suppressed because it is too large Load Diff

View 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';