- 미사용 코드 삭제: ThemeContext, itemStore, utils/date.ts, utils/formatAmount.ts - 유틸리티 이동: date, formatAmount → src/lib/utils/ (중앙 집중화) - 다수 page.tsx 클라이언트 컴포넌트 패턴 통일 - DateRangeSelector 리팩토링 및 date-range-picker UI 컴포넌트 추가 - ThemeSelect/themeStore Zustand 직접 연동으로 전환 - 건설/회계/영업/품목/출하 등 전반적 컴포넌트 개선 - UniversalListPage, IntegratedListTemplateV2 타입 확장 - 프론트엔드 종합 리뷰 문서 및 개선 체크리스트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
/**
|
||
* 품목 등록/수정 폼 컴포넌트
|
||
*
|
||
* react-hook-form + Zod 검증
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useForm } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import type { ItemType } from '@/types/item';
|
||
import { createItemFormSchema, type CreateItemFormData } from '@/lib/utils/validation';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import ItemTypeSelect from '../ItemTypeSelect';
|
||
import { DrawingCanvas } from '../DrawingCanvas';
|
||
|
||
// Local imports
|
||
import { PART_TYPE_CATEGORIES, PART_ITEM_NAMES } from './constants';
|
||
import type { ItemFormProps } from './types';
|
||
import ValidationAlert from './ValidationAlert';
|
||
import FormHeader from './FormHeader';
|
||
import BendingDiagramSection from './BendingDiagramSection';
|
||
import BOMSection from './BOMSection';
|
||
import { MaterialForm, ProductForm, ProductCertificationSection, PartForm } from './forms';
|
||
import { useItemFormState } from './hooks';
|
||
import { toast } from 'sonner';
|
||
|
||
export default function ItemForm({ mode, initialData, onSubmit }: ItemFormProps) {
|
||
const router = useRouter();
|
||
|
||
// 커스텀 훅으로 상태 관리 통합
|
||
const {
|
||
// 기본 상태
|
||
isSubmitting, setIsSubmitting,
|
||
selectedItemType, setSelectedItemType,
|
||
// BOM 상태
|
||
bomLines, setBomLines,
|
||
bomSearchStates, setBomSearchStates,
|
||
// 파일 상태
|
||
specificationFile, setSpecificationFile,
|
||
certificationFile, setCertificationFile,
|
||
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,
|
||
} = useItemFormState({ mode, initialData });
|
||
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
formState: { errors },
|
||
setValue,
|
||
getValues,
|
||
clearErrors,
|
||
} = useForm<CreateItemFormData>({
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
resolver: zodResolver(createItemFormSchema) as any,
|
||
defaultValues: initialData || {
|
||
itemType: 'FG',
|
||
unit: 'EA',
|
||
isActive: true,
|
||
currentRevision: 0,
|
||
isFinal: false,
|
||
},
|
||
});
|
||
|
||
// 전개도 상세 입력 변경 시 폭 합계 자동 업데이트
|
||
useEffect(() => {
|
||
if (bendingDetails.length > 0) {
|
||
const totalSum = bendingDetails.reduce((acc, d) => {
|
||
const calc = d.input + d.elongation;
|
||
return acc + calc;
|
||
}, 0);
|
||
setWidthSum(totalSum.toFixed(1));
|
||
setValue('length', totalSum.toFixed(1));
|
||
}
|
||
}, [bendingDetails, setValue, setWidthSum]);
|
||
|
||
// 품목코드 자동 생성 함수
|
||
const generateItemCode = () => {
|
||
if (selectedPartType === "BENDING" && selectedCategory1 && selectedBendingItemType) {
|
||
const categoryData = PART_TYPE_CATEGORIES.BENDING.categories.find(
|
||
cat => cat.value === selectedCategory1
|
||
);
|
||
const categoryCode = categoryData?.code || '';
|
||
const itemTypeData = PART_ITEM_NAMES[selectedCategory1]?.find(
|
||
item => item.label === selectedBendingItemType
|
||
);
|
||
const itemTypeCode = itemTypeData?.code || '';
|
||
|
||
let lengthCode = "";
|
||
if (bendingLength) {
|
||
if (bendingLength.startsWith("W")) {
|
||
if (bendingLength === "W50x3000") lengthCode = "53";
|
||
else if (bendingLength === "W50x4000") lengthCode = "54";
|
||
else if (bendingLength === "W80x3000") lengthCode = "83";
|
||
else if (bendingLength === "W80x4000") lengthCode = "84";
|
||
else {
|
||
const match = bendingLength.match(/W(\d+)x(\d+)/);
|
||
if (match) {
|
||
const width = parseInt(match[1]);
|
||
const length = parseInt(match[2]);
|
||
lengthCode = `${Math.floor(width / 10)}${Math.floor(length / 1000)}`;
|
||
}
|
||
}
|
||
} else {
|
||
const lengthNum = parseInt(bendingLength);
|
||
const lengthMap: Record<number, string> = {
|
||
1219: "12", 2438: "24", 3000: "30", 3500: "35",
|
||
4000: "40", 4150: "41", 4200: "42", 4300: "43"
|
||
};
|
||
lengthCode = lengthMap[lengthNum] || Math.floor(lengthNum / 100).toString().padStart(2, '0');
|
||
}
|
||
}
|
||
return `${categoryCode}${itemTypeCode}${lengthCode}`;
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const handleFormSubmit = async (data: CreateItemFormData) => {
|
||
setIsSubmitting(true);
|
||
try {
|
||
const finalData = {
|
||
...data,
|
||
bom: bomLines.length > 0 ? bomLines : undefined,
|
||
bendingDetails: bendingDetails.length > 0 ? bendingDetails : undefined,
|
||
specificationFileName: specificationFile?.name,
|
||
certificationFileName: certificationFile?.name,
|
||
};
|
||
await onSubmit(finalData);
|
||
router.push('/production/screen-production');
|
||
router.refresh();
|
||
} catch {
|
||
toast.error('품목 저장에 실패했습니다.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleItemTypeChange = (type: ItemType) => {
|
||
setSelectedItemType(type);
|
||
setValue('itemType', type);
|
||
resetAllStates(setValue, clearErrors, type);
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||
{/* Validation 에러 Alert */}
|
||
<ValidationAlert errors={errors} />
|
||
|
||
{/* 헤더 */}
|
||
<FormHeader
|
||
mode={mode}
|
||
selectedItemType={selectedItemType}
|
||
isSubmitting={isSubmitting}
|
||
onCancel={() => router.back()}
|
||
/>
|
||
|
||
{/* 기본 정보 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>기본 정보</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* 1단계: 품목 유형 먼저 선택 */}
|
||
<div className="md:col-span-2">
|
||
<ItemTypeSelect
|
||
value={selectedItemType}
|
||
onChange={handleItemTypeChange}
|
||
disabled={mode === 'edit'}
|
||
required
|
||
/>
|
||
{errors.itemType && (
|
||
<p className="text-sm text-red-500 mt-1">
|
||
{errors.itemType.message}
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
* 품목 유형에 따라 입력 항목이 다릅니다
|
||
</p>
|
||
</div>
|
||
|
||
{/* 2단계: 품목 유형이 선택된 경우에만 표시 */}
|
||
{selectedItemType && (
|
||
<>
|
||
{/* 제품(FG)인 경우 */}
|
||
{selectedItemType === 'FG' && (
|
||
<ProductForm
|
||
productName={productName}
|
||
setProductName={setProductName}
|
||
productStatus={productStatus}
|
||
setProductStatus={setProductStatus}
|
||
remarks={remarks}
|
||
setRemarks={setRemarks}
|
||
needsBOM={needsBOM}
|
||
setNeedsBOM={setNeedsBOM}
|
||
specificationFile={specificationFile}
|
||
setSpecificationFile={setSpecificationFile}
|
||
certificationFile={certificationFile}
|
||
setCertificationFile={setCertificationFile}
|
||
isSubmitting={isSubmitting}
|
||
register={register}
|
||
setValue={setValue}
|
||
getValues={getValues}
|
||
errors={errors}
|
||
/>
|
||
)}
|
||
|
||
{/* 부품(PT)인 경우 */}
|
||
{selectedItemType === 'PT' && (
|
||
<PartForm
|
||
selectedPartType={selectedPartType}
|
||
setSelectedPartType={setSelectedPartType}
|
||
selectedCategory1={selectedCategory1}
|
||
setSelectedCategory1={setSelectedCategory1}
|
||
selectedInstallationType={selectedInstallationType}
|
||
setSelectedInstallationType={setSelectedInstallationType}
|
||
sideSpecWidth={sideSpecWidth}
|
||
setSideSpecWidth={setSideSpecWidth}
|
||
sideSpecHeight={sideSpecHeight}
|
||
setSideSpecHeight={setSideSpecHeight}
|
||
assemblyLength={assemblyLength}
|
||
setAssemblyLength={setAssemblyLength}
|
||
assemblyUnit={assemblyUnit}
|
||
setAssemblyUnit={setAssemblyUnit}
|
||
selectedBendingItemType={selectedBendingItemType}
|
||
setSelectedBendingItemType={setSelectedBendingItemType}
|
||
material={material}
|
||
setMaterial={setMaterial}
|
||
widthSum={widthSum}
|
||
setWidthSum={setWidthSum}
|
||
bendingLength={bendingLength}
|
||
setBendingLength={setBendingLength}
|
||
partUnit={partUnit}
|
||
setPartUnit={setPartUnit}
|
||
bendingDetailsLength={bendingDetails.length}
|
||
electricOpenerPower={electricOpenerPower}
|
||
setElectricOpenerPower={setElectricOpenerPower}
|
||
electricOpenerCapacity={electricOpenerCapacity}
|
||
setElectricOpenerCapacity={setElectricOpenerCapacity}
|
||
motorVoltage={motorVoltage}
|
||
setMotorVoltage={setMotorVoltage}
|
||
chainSpec={chainSpec}
|
||
setChainSpec={setChainSpec}
|
||
partStatus={partStatus}
|
||
setPartStatus={setPartStatus}
|
||
needsBOM={needsBOM}
|
||
setNeedsBOM={setNeedsBOM}
|
||
generateItemCode={generateItemCode}
|
||
register={register}
|
||
setValue={setValue}
|
||
clearErrors={clearErrors}
|
||
errors={errors}
|
||
/>
|
||
)}
|
||
|
||
{/* SM/RM/CS 공통 섹션 */}
|
||
{(selectedItemType === 'RM' || selectedItemType === 'SM' || selectedItemType === 'CS') && (
|
||
<MaterialForm
|
||
selectedItemType={selectedItemType}
|
||
itemName={itemName}
|
||
setItemName={setItemName}
|
||
selectedSpecification={selectedSpecification}
|
||
setSelectedSpecification={setSelectedSpecification}
|
||
materialStatus={materialStatus}
|
||
setMaterialStatus={setMaterialStatus}
|
||
selectedUnit={selectedUnit}
|
||
setSelectedUnit={setSelectedUnit}
|
||
register={register}
|
||
setValue={setValue}
|
||
getValues={getValues}
|
||
errors={errors}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 비고 필드 (PT를 제외한 모든 품목 유형) */}
|
||
{selectedItemType && selectedItemType !== 'PT' && (
|
||
<div className="mt-4">
|
||
<Label htmlFor="note">비고</Label>
|
||
<Input
|
||
id="note"
|
||
placeholder="비고사항을 입력하세요"
|
||
{...register('note')}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 인정 정보 (FG only) */}
|
||
{selectedItemType === 'FG' && (
|
||
<ProductCertificationSection
|
||
remarks={remarks}
|
||
setRemarks={setRemarks}
|
||
needsBOM={needsBOM}
|
||
setNeedsBOM={setNeedsBOM}
|
||
specificationFile={specificationFile}
|
||
setSpecificationFile={setSpecificationFile}
|
||
certificationFile={certificationFile}
|
||
setCertificationFile={setCertificationFile}
|
||
isSubmitting={isSubmitting}
|
||
register={register}
|
||
setValue={setValue}
|
||
getValues={getValues}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 품목 유형 선택 안내 경고 */}
|
||
{!selectedItemType && (
|
||
<Alert className="bg-orange-50 border-orange-200">
|
||
<AlertDescription className="text-orange-900">
|
||
⚠️ 품목 유형을 먼저 선택해주세요
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 절곡품 전개도 - PT ASSEMBLY(category1 선택 시) 또는 PT BENDING */}
|
||
{selectedItemType === 'PT' && (
|
||
(selectedPartType === 'ASSEMBLY' && selectedCategory1) ||
|
||
selectedPartType === 'BENDING'
|
||
) && (
|
||
<BendingDiagramSection
|
||
selectedPartType={selectedPartType}
|
||
bendingDiagramInputMethod={bendingDiagramInputMethod}
|
||
setBendingDiagramInputMethod={setBendingDiagramInputMethod}
|
||
bendingDiagram={bendingDiagram}
|
||
setBendingDiagram={setBendingDiagram}
|
||
setBendingDiagramFile={setBendingDiagramFile}
|
||
setIsDrawingOpen={setIsDrawingOpen}
|
||
bendingDetails={bendingDetails}
|
||
setBendingDetails={setBendingDetails}
|
||
setWidthSum={setWidthSum}
|
||
setValue={setValue}
|
||
isSubmitting={isSubmitting}
|
||
/>
|
||
)}
|
||
|
||
{/* 부품 구성 (BOM) - 제품 또는 부품(needsBOM 체크 시) */}
|
||
{(
|
||
selectedItemType === 'FG' ||
|
||
(selectedItemType === 'PT' && selectedPartType === 'ASSEMBLY' && selectedCategory1) ||
|
||
(selectedItemType === 'PT' && selectedPartType === 'PURCHASED')
|
||
) && needsBOM && (
|
||
<BOMSection
|
||
bomLines={bomLines}
|
||
setBomLines={setBomLines}
|
||
bomSearchStates={bomSearchStates}
|
||
setBomSearchStates={setBomSearchStates}
|
||
isSubmitting={isSubmitting}
|
||
/>
|
||
)}
|
||
|
||
{/* 전개도 그리기 캔버스 */}
|
||
<DrawingCanvas
|
||
open={isDrawingOpen}
|
||
onOpenChange={setIsDrawingOpen}
|
||
onSave={(imageData) => {
|
||
setBendingDiagram(imageData);
|
||
setIsDrawingOpen(false);
|
||
}}
|
||
initialImage={bendingDiagram}
|
||
title={selectedPartType === 'ASSEMBLY' ? '조립품 전개도 그리기' : '절곡품 전개도 그리기'}
|
||
description="전개도를 직접 그리거나 편집합니다."
|
||
/>
|
||
|
||
</form>
|
||
);
|
||
} |