diff --git a/src/app/[locale]/(protected)/sales/stocks/page.tsx b/src/app/[locale]/(protected)/sales/stocks/page.tsx index a4b1cb73..f439002a 100644 --- a/src/app/[locale]/(protected)/sales/stocks/page.tsx +++ b/src/app/[locale]/(protected)/sales/stocks/page.tsx @@ -4,23 +4,19 @@ * 재고생산관리 페이지 * * - 기본: 목록 (StockProductionList) - * - ?mode=new: 등록 (StockProductionForm) + * - ?mode=new: 등록 (BendingLotForm — 절곡품 LOT 방식) */ import { useSearchParams } from 'next/navigation'; import { StockProductionList } from '@/components/stocks/StockProductionList'; -import { StockProductionForm } from '@/components/stocks/StockProductionForm'; - -function CreateStockContent() { - return ; -} +import { BendingLotForm } from '@/components/stocks/BendingLotForm'; export default function StocksPage() { const searchParams = useSearchParams(); const mode = searchParams.get('mode'); if (mode === 'new') { - return ; + return ; } return ; diff --git a/src/components/stocks/BendingLotForm.tsx b/src/components/stocks/BendingLotForm.tsx new file mode 100644 index 00000000..7ca1ad0b --- /dev/null +++ b/src/components/stocks/BendingLotForm.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { NumberInput } from '@/components/ui/number-input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from '@/components/ui/select'; +import { Package, ClipboardList, MessageSquare, Tag, Layers } from 'lucide-react'; +import { toast } from 'sonner'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { FormSection } from '@/components/organisms/FormSection'; +import { + getBendingCodeMap, + resolveBendingItem, + generateBendingLot, + createBendingStockOrder, + type BendingCodeMap, + type BendingResolvedItem, +} from './actions'; +import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types'; + +// ============================================================================ +// Config +// ============================================================================ + +const bendingCreateConfig: DetailConfig = { + title: '절곡품 재고생산', + description: '절곡품 재고생산을 등록합니다', + icon: Package, + basePath: '/sales/stocks', + fields: [], + actions: { + showBack: true, + showSave: true, + submitLabel: '저장', + backLabel: '취소', + }, +}; + +// ============================================================================ +// LOT 프리뷰 날짜코드 생성 +// ============================================================================ + +function generateDateCode(dateStr: string): string { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ''; + const year = date.getFullYear() % 10; + const month = date.getMonth() + 1; + const day = date.getDate(); + const monthCode = + month >= 10 ? String.fromCharCode(55 + month) : String(month); + return `${year}${monthCode}${String(day).padStart(2, '0')}`; +} + +// ============================================================================ +// 폼 상태 +// ============================================================================ + +interface BendingFormState { + regDate: string; + prodCode: string; + specCode: string; + lengthCode: string; + quantity: number; + rawLotNo: string; + fabricLotNo: string; + memo: string; +} + +function getInitialForm(): BendingFormState { + const today = new Date(); + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, '0'); + const dd = String(today.getDate()).padStart(2, '0'); + return { + regDate: `${yyyy}-${mm}-${dd}`, + prodCode: '', + specCode: '', + lengthCode: '', + quantity: 1, + rawLotNo: '', + fabricLotNo: '', + memo: '', + }; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function BendingLotForm() { + const router = useRouter(); + const params = useParams(); + const locale = (params.locale as string) || 'ko'; + const basePath = `/${locale}/sales/stocks`; + + const [form, setForm] = useState(getInitialForm); + const [codeMap, setCodeMap] = useState(null); + const [resolvedItem, setResolvedItem] = useState(null); + const [resolveError, setResolveError] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // 코드맵 로드 + useEffect(() => { + async function loadCodeMap() { + const result = await getBendingCodeMap(); + if (result.__authError) { + toast.error('인증이 만료되었습니다.'); + return; + } + if (result.success && result.data) { + setCodeMap(result.data); + } else { + toast.error(result.error || '코드맵 로딩에 실패했습니다.'); + } + setIsLoading(false); + } + loadCodeMap(); + }, []); + + // 필터링된 종류 목록 + const filteredSpecs = useMemo(() => { + if (!codeMap || !form.prodCode) return []; + return codeMap.specs.filter((s) => s.products.includes(form.prodCode)); + }, [codeMap, form.prodCode]); + + // 필터링된 길이 목록 + const filteredLengths = useMemo(() => { + if (!codeMap || !form.prodCode) return []; + return form.prodCode === 'G' + ? codeMap.lengths.smoke_barrier + : codeMap.lengths.general; + }, [codeMap, form.prodCode]); + + // 재질 + const material = useMemo(() => { + if (!codeMap || !form.prodCode || !form.specCode) return ''; + return codeMap.material_map[`${form.prodCode}:${form.specCode}`] || ''; + }, [codeMap, form.prodCode, form.specCode]); + + // LOT 프리뷰 + const lotPreview = useMemo(() => { + if (!form.prodCode || !form.specCode || !form.regDate) return ''; + const dateCode = generateDateCode(form.regDate); + if (!dateCode) return ''; + const base = `${form.prodCode}${form.specCode}${dateCode}`; + return form.lengthCode ? `${base}-${form.lengthCode}` : base; + }, [form.prodCode, form.specCode, form.lengthCode, form.regDate]); + + // 연기차단재 여부 + const isSmokeBarrier = form.prodCode === 'G'; + + // 품목명 변경 + const handleProdChange = useCallback((value: string) => { + setForm((prev) => ({ + ...prev, + prodCode: value, + specCode: '', + lengthCode: '', + })); + setResolvedItem(null); + setResolveError(''); + }, []); + + // 종류 변경 + const handleSpecChange = useCallback((value: string) => { + setForm((prev) => ({ + ...prev, + specCode: value, + lengthCode: '', + })); + setResolvedItem(null); + setResolveError(''); + }, []); + + // 모양&길이 변경 → 품목 매핑 조회 + const handleLengthChange = useCallback( + async (value: string) => { + setForm((prev) => ({ ...prev, lengthCode: value })); + setResolvedItem(null); + setResolveError(''); + + if (form.prodCode && form.specCode && value) { + const result = await resolveBendingItem(form.prodCode, form.specCode, value); + if (result.__authError) { + toast.error('인증이 만료되었습니다.'); + return; + } + if (result.success && result.data) { + setResolvedItem(result.data); + } else { + setResolveError('해당 조합에 매핑된 품목이 없습니다.'); + } + } + }, + [form.prodCode, form.specCode] + ); + + // 저장 + const handleSave = useCallback(async () => { + if (!form.prodCode) { + toast.error('품목명을 선택하세요.'); + return; + } + if (!form.specCode) { + toast.error('종류를 선택하세요.'); + return; + } + if (!form.lengthCode) { + toast.error('모양&길이를 선택하세요.'); + return; + } + if (form.quantity < 1) { + toast.error('수량을 입력하세요.'); + return; + } + + setIsSaving(true); + try { + // 1. LOT 번호 생성 + const lotResult = await generateBendingLot( + form.prodCode, + form.specCode, + form.lengthCode, + form.regDate + ); + if (lotResult.__authError) { + toast.error('인증이 만료되었습니다.'); + return; + } + if (!lotResult.success || !lotResult.data) { + toast.error(lotResult.error || 'LOT 번호 생성에 실패했습니다.'); + return; + } + + const lotData = lotResult.data; + + // 2. 재고생산 저장 + const itemName = + resolvedItem?.item_name || + `${codeMap?.products.find((p) => p.code === form.prodCode)?.name || form.prodCode} ${codeMap?.specs.find((s) => s.code === form.specCode)?.name || form.specCode}`; + + const saveResult = await createBendingStockOrder({ + memo: form.memo, + targetStockQty: form.quantity, + bendingLot: { + lot_number: lotData.lot_number, + prod_code: form.prodCode, + spec_code: form.specCode, + length_code: form.lengthCode, + raw_lot_no: form.rawLotNo || undefined, + fabric_lot_no: isSmokeBarrier ? form.fabricLotNo || undefined : undefined, + material: lotData.material || material, + }, + item: { + itemId: resolvedItem?.item_id, + itemCode: resolvedItem?.item_code, + itemName, + specification: resolvedItem?.specification || material, + quantity: form.quantity, + unit: resolvedItem?.unit || 'EA', + }, + }); + + if (saveResult.__authError) { + toast.error('인증이 만료되었습니다.'); + return; + } + if (!saveResult.success) { + toast.error(saveResult.error || '저장에 실패했습니다.'); + return; + } + + toast.success('절곡품 재고생산이 등록되었습니다.'); + if (saveResult.data?.id) { + router.push(`${basePath}/${saveResult.data.id}`); + } else { + router.push(basePath); + } + } finally { + setIsSaving(false); + } + }, [form, resolvedItem, codeMap, material, isSmokeBarrier, router, basePath]); + + // 취소 + const handleCancel = useCallback(() => { + router.push(basePath); + }, [router, basePath]); + + // renderForm + const renderFormContent = useMemo( + () => () => ( +
+ {/* 기본 정보 */} + +
+
+ + setForm((prev) => ({ ...prev, regDate: date }))} + /> +
+
+ + + setForm((prev) => ({ ...prev, quantity: value ?? 1 })) + } + min={1} + placeholder="수량" + /> +
+
+
+ + {/* 품목 선택 (캐스케이딩) */} + +
+ {/* 품목명 */} +
+ + +
+ + {/* 종류 */} +
+ + +
+ + {/* 모양&길이 */} +
+ + +
+
+ + {/* 매핑 결과 */} + {resolvedItem && ( +
+

매핑된 품목

+
+
+ 품목코드: + {resolvedItem.item_code} +
+
+ 품목명: + {resolvedItem.item_name} +
+
+ 규격: + {resolvedItem.specification} +
+
+ 단위: + {resolvedItem.unit} +
+
+
+ )} + {resolveError && ( +
+

{resolveError}

+

+ 매핑 없이도 저장 가능합니다. 추후 관리자가 매핑을 등록하면 연결됩니다. +

+
+ )} +
+ + {/* LOT 정보 */} + +
+
+ + +

+ 일련번호는 저장 시 자동 확정됩니다 +

+
+
+ + +
+
+ +
+
+ + + setForm((prev) => ({ ...prev, rawLotNo: e.target.value })) + } + /> +
+ {isSmokeBarrier && ( +
+ + + setForm((prev) => ({ ...prev, fabricLotNo: e.target.value })) + } + /> +
+ )} +
+
+ + {/* 메모 */} + +
+ +