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="수량" + /> + + + + + {/* 품목 선택 (캐스케이딩) */} + + + {/* 품목명 */} + + 품목명 * + + + + + + {codeMap?.products.map((p) => ( + + {p.name} + + ))} + + + + + {/* 종류 */} + + 종류 * + + + + + + {filteredSpecs.map((s) => ( + + {s.name} + + ))} + + + + + {/* 모양&길이 */} + + 모양&길이 * + + + + + + {filteredLengths.map((l) => ( + + {l.name} + + ))} + + + + + + {/* 매핑 결과 */} + {resolvedItem && ( + + 매핑된 품목 + + + 품목코드: + {resolvedItem.item_code} + + + 품목명: + {resolvedItem.item_name} + + + 규격: + {resolvedItem.specification} + + + 단위: + {resolvedItem.unit} + + + + )} + {resolveError && ( + + {resolveError} + + 매핑 없이도 저장 가능합니다. 추후 관리자가 매핑을 등록하면 연결됩니다. + + + )} + + + {/* LOT 정보 */} + + + + 생산품 LOT (프리뷰) + + + 일련번호는 저장 시 자동 확정됩니다 + + + + 원자재 재질 + + + + + + + 원자재(철판) LOT + + setForm((prev) => ({ ...prev, rawLotNo: e.target.value })) + } + /> + + {isSmokeBarrier && ( + + 원단 LOT + + setForm((prev) => ({ ...prev, fabricLotNo: e.target.value })) + } + /> + + )} + + + + {/* 메모 */} + + + 메모 + + setForm((prev) => ({ ...prev, memo: e.target.value })) + } + rows={3} + /> + + + + ), + [ + form, + codeMap, + filteredSpecs, + filteredLengths, + material, + lotPreview, + resolvedItem, + resolveError, + isSmokeBarrier, + handleProdChange, + handleSpecChange, + handleLengthChange, + ] + ); + + return ( + + ); +} diff --git a/src/components/stocks/StockProductionDetail.tsx b/src/components/stocks/StockProductionDetail.tsx index 02710136..82115a80 100644 --- a/src/components/stocks/StockProductionDetail.tsx +++ b/src/components/stocks/StockProductionDetail.tsx @@ -28,6 +28,7 @@ import { Factory, ClipboardList, MessageSquare, + Tag, } from 'lucide-react'; import { toast } from 'sonner'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; @@ -238,7 +239,7 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) { icon: Factory, label: '생산지시 생성', onClick: handleCreateProductionOrder, - className: 'bg-green-600 hover:bg-green-700 text-white', + className: 'bg-green-600 hover:bg-green-500 text-white', disabled: isProcessing, }); items.push({ @@ -310,6 +311,28 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) { + {/* LOT 정보 (절곡품) */} + {data.bendingLot && ( + + + + + LOT 정보 + + + + + + + + {data.bendingLot.prodCode === 'G' && ( + + )} + + + + )} + {/* 비고 */} {(data.memo || data.remarks) && ( diff --git a/src/components/stocks/actions.ts b/src/components/stocks/actions.ts index 6a1f9dcc..6bc0b470 100644 --- a/src/components/stocks/actions.ts +++ b/src/components/stocks/actions.ts @@ -26,6 +26,15 @@ interface ApiStockOrder { production_reason?: string; target_stock_qty?: number; manager_name?: string; + bending_lot?: { + lot_number?: string; + prod_code?: string; + spec_code?: string; + length_code?: string; + raw_lot_no?: string; + fabric_lot_no?: string; + material?: string; + }; } | null; created_by: number | null; updated_by: number | null; @@ -90,6 +99,15 @@ export interface StockOrder { itemSummary: string; createdAt: string; items: StockOrderItem[]; + bendingLot?: { + lotNumber: string; + prodCode: string; + specCode: string; + lengthCode: string; + rawLotNo?: string; + fabricLotNo?: string; + material?: string; + }; } export interface StockOrderItem { @@ -168,6 +186,8 @@ function transformApiToFrontend(apiData: ApiStockOrder): StockOrder { const firstItemName = items[0]?.itemName || ''; const extraCount = items.length > 1 ? ` 외 ${items.length - 1}건` : ''; + const bendingLotData = apiData.options?.bending_lot; + return { id: String(apiData.id), orderNo: apiData.order_no, @@ -184,6 +204,15 @@ function transformApiToFrontend(apiData: ApiStockOrder): StockOrder { itemSummary: firstItemName ? `${firstItemName}${extraCount}` : '-', createdAt: formatDate(apiData.created_at), items, + bendingLot: bendingLotData ? { + lotNumber: bendingLotData.lot_number || '', + prodCode: bendingLotData.prod_code || '', + specCode: bendingLotData.spec_code || '', + lengthCode: bendingLotData.length_code || '', + rawLotNo: bendingLotData.raw_lot_no, + fabricLotNo: bendingLotData.fabric_lot_no, + material: bendingLotData.material, + } : undefined, }; } @@ -478,3 +507,204 @@ export async function createStockProductionOrder( if (!result.success || !result.data) return { success: false, error: result.error }; return { success: true, data: transformApiToFrontend(result.data.order) }; } + +// ============================================================================ +// 절곡품 LOT API 타입 정의 +// ============================================================================ + +export interface BendingProduct { + code: string; + name: string; +} + +export interface BendingSpec { + code: string; + name: string; + products: string[]; +} + +export interface BendingLength { + code: string; + name: string; +} + +export interface BendingCodeMap { + products: BendingProduct[]; + specs: BendingSpec[]; + lengths: { + smoke_barrier: BendingLength[]; + general: BendingLength[]; + }; + material_map: Record; +} + +export interface BendingResolvedItem { + item_id: number; + item_code: string; + item_name: string; + specification: string; + unit: string; +} + +export interface BendingLotResult { + lot_base: string; + lot_number: string; + date_code: string; + material: string; +} + +export interface BendingLotFormData { + lot_number: string; + prod_code: string; + spec_code: string; + length_code: string; + raw_lot_no?: string; + fabric_lot_no?: string; + material?: string; +} + +// ============================================================================ +// 절곡품 LOT API 함수 +// ============================================================================ + +/** + * 절곡품 코드맵 조회 (캐스케이딩 드롭다운 옵션) + */ +export async function getBendingCodeMap(): Promise<{ + success: boolean; + data?: BendingCodeMap; + error?: string; + __authError?: boolean; +}> { + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/bending/code-map'), + errorMessage: '절곡품 코드맵 조회에 실패했습니다.', + }); + if (result.__authError) return { success: false, __authError: true }; + return { success: result.success, data: result.data, error: result.error }; +} + +/** + * 절곡품 품목 매핑 조회 (드롭다운 3개 선택 후) + */ +export async function resolveBendingItem( + prod: string, + spec: string, + length: string +): Promise<{ + success: boolean; + data?: BendingResolvedItem; + error?: string; + __authError?: boolean; +}> { + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/bending/resolve-item', { prod, spec, length }), + errorMessage: '품목 매핑 조회에 실패했습니다.', + }); + if (result.__authError) return { success: false, __authError: true }; + return { success: result.success, data: result.data, error: result.error }; +} + +/** + * 절곡품 LOT 번호 생성 + */ +export async function generateBendingLot( + prodCode: string, + specCode: string, + lengthCode: string, + regDate?: string +): Promise<{ + success: boolean; + data?: BendingLotResult; + error?: string; + __authError?: boolean; +}> { + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/bending/generate-lot'), + method: 'POST', + body: { + prod_code: prodCode, + spec_code: specCode, + length_code: lengthCode, + reg_date: regDate, + }, + errorMessage: 'LOT 번호 생성에 실패했습니다.', + }); + if (result.__authError) return { success: false, __authError: true }; + return { success: result.success, data: result.data, error: result.error }; +} + +/** + * 절곡품 재고생산 저장 (기존 orders API + bending_lot 확장) + */ +export async function createBendingStockOrder(params: { + memo?: string; + targetStockQty: number; + bendingLot: BendingLotFormData; + item: { + itemId?: number; + itemCode?: string; + itemName: string; + specification?: string; + quantity: number; + unit?: string; + }; +}): Promise<{ + success: boolean; + data?: StockOrder; + error?: string; + __authError?: boolean; +}> { + const apiData = { + order_type_code: 'STOCK', + memo: params.memo || null, + remarks: null, + options: { + production_reason: '절곡품 재고생산', + target_stock_qty: params.targetStockQty || null, + bending_lot: { + lot_number: params.bendingLot.lot_number, + prod_code: params.bendingLot.prod_code, + spec_code: params.bendingLot.spec_code, + length_code: params.bendingLot.length_code, + raw_lot_no: params.bendingLot.raw_lot_no || null, + fabric_lot_no: params.bendingLot.fabric_lot_no || null, + material: params.bendingLot.material || null, + }, + }, + client_id: null, + client_name: null, + site_name: null, + delivery_date: null, + delivery_method_code: null, + discount_rate: 0, + discount_amount: 0, + supply_amount: 0, + tax_amount: 0, + total_amount: 0, + items: [ + { + item_id: params.item.itemId || null, + item_code: params.item.itemCode || null, + item_name: params.item.itemName, + specification: params.item.specification || null, + quantity: params.item.quantity, + unit: params.item.unit || 'EA', + unit_price: 0, + supply_amount: 0, + tax_amount: 0, + total_amount: 0, + }, + ], + }; + + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/orders'), + method: 'POST', + body: apiData, + transform: (d: ApiStockOrder) => transformApiToFrontend(d), + errorMessage: '절곡품 재고생산 등록에 실패했습니다.', + }); + if (result.__authError) return { success: false, __authError: true }; + return { success: result.success, data: result.data, error: result.error }; +}
매핑된 품목
{resolvedItem.item_code}
{resolveError}
+ 매핑 없이도 저장 가능합니다. 추후 관리자가 매핑을 등록하면 연결됩니다. +
+ 일련번호는 저장 시 자동 확정됩니다 +