feat: [stocks] 벤딩 LOT 폼 추가 + 재고 상세/액션 보강

This commit is contained in:
유병철
2026-03-17 13:47:09 +09:00
parent 1a3538863d
commit 9b6f4c6684
4 changed files with 776 additions and 8 deletions

View File

@@ -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 <StockProductionForm />;
}
import { BendingLotForm } from '@/components/stocks/BendingLotForm';
export default function StocksPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') {
return <CreateStockContent />;
return <BendingLotForm />;
}
return <StockProductionList />;

View File

@@ -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<BendingFormState>(getInitialForm);
const [codeMap, setCodeMap] = useState<BendingCodeMap | null>(null);
const [resolvedItem, setResolvedItem] = useState<BendingResolvedItem | null>(null);
const [resolveError, setResolveError] = useState<string>('');
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(
() => () => (
<div className="space-y-6">
{/* 기본 정보 */}
<FormSection title="기본 정보" icon={ClipboardList}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<DatePicker
value={form.regDate}
onChange={(date) => setForm((prev) => ({ ...prev, regDate: date }))}
/>
</div>
<div className="space-y-2">
<Label></Label>
<NumberInput
value={form.quantity}
onChange={(value) =>
setForm((prev) => ({ ...prev, quantity: value ?? 1 }))
}
min={1}
placeholder="수량"
/>
</div>
</div>
</FormSection>
{/* 품목 선택 (캐스케이딩) */}
<FormSection title="품목 선택" icon={Layers}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 품목명 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`prod-${form.prodCode}`}
value={form.prodCode}
onValueChange={handleProdChange}
>
<SelectTrigger>
<SelectValue placeholder="품목 선택" />
</SelectTrigger>
<SelectContent>
{codeMap?.products.map((p) => (
<SelectItem key={p.code} value={p.code}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 종류 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`spec-${form.prodCode}-${form.specCode}`}
value={form.specCode}
onValueChange={handleSpecChange}
disabled={!form.prodCode}
>
<SelectTrigger>
<SelectValue placeholder="종류 선택" />
</SelectTrigger>
<SelectContent>
{filteredSpecs.map((s) => (
<SelectItem key={s.code} value={s.code}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 모양&길이 */}
<div className="space-y-2">
<Label>& <span className="text-red-500">*</span></Label>
<Select
key={`length-${form.prodCode}-${form.specCode}-${form.lengthCode}`}
value={form.lengthCode}
onValueChange={handleLengthChange}
disabled={!form.specCode}
>
<SelectTrigger>
<SelectValue placeholder="모양&길이 선택" />
</SelectTrigger>
<SelectContent>
{filteredLengths.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 매핑 결과 */}
{resolvedItem && (
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
<p className="text-sm font-medium text-green-800"> </p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-2 text-sm">
<div>
<span className="text-muted-foreground">: </span>
<code className="bg-green-100 px-1 rounded">{resolvedItem.item_code}</code>
</div>
<div>
<span className="text-muted-foreground">: </span>
{resolvedItem.item_name}
</div>
<div>
<span className="text-muted-foreground">: </span>
{resolvedItem.specification}
</div>
<div>
<span className="text-muted-foreground">: </span>
{resolvedItem.unit}
</div>
</div>
</div>
)}
{resolveError && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-800">{resolveError}</p>
<p className="text-xs text-yellow-600 mt-1">
. .
</p>
</div>
)}
</FormSection>
{/* LOT 정보 */}
<FormSection title="LOT 정보" icon={Tag}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> LOT ()</Label>
<Input
value={lotPreview ? `${lotPreview}-___` : ''}
disabled
placeholder="품목/종류/등록일 선택 시 자동 생성"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label> </Label>
<Input value={material} disabled placeholder="품목/종류 선택 시 자동 결정" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="space-y-2">
<Label>() LOT</Label>
<Input
placeholder="원자재 LOT 번호 입력 (선택)"
value={form.rawLotNo}
onChange={(e) =>
setForm((prev) => ({ ...prev, rawLotNo: e.target.value }))
}
/>
</div>
{isSmokeBarrier && (
<div className="space-y-2">
<Label> LOT</Label>
<Input
placeholder="원단 LOT 번호 입력 (선택)"
value={form.fabricLotNo}
onChange={(e) =>
setForm((prev) => ({ ...prev, fabricLotNo: e.target.value }))
}
/>
</div>
)}
</div>
</FormSection>
{/* 메모 */}
<FormSection title="메모" icon={MessageSquare}>
<div className="space-y-2">
<Label></Label>
<Textarea
placeholder="메모를 입력하세요 (선택)"
value={form.memo}
onChange={(e) =>
setForm((prev) => ({ ...prev, memo: e.target.value }))
}
rows={3}
/>
</div>
</FormSection>
</div>
),
[
form,
codeMap,
filteredSpecs,
filteredLengths,
material,
lotPreview,
resolvedItem,
resolveError,
isSmokeBarrier,
handleProdChange,
handleSpecChange,
handleLengthChange,
]
);
return (
<IntegratedDetailTemplate
config={bendingCreateConfig}
mode="create"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={handleSave}
renderForm={renderFormContent}
/>
);
}

View File

@@ -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) {
</CardContent>
</Card>
{/* LOT 정보 (절곡품) */}
{data.bendingLot && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tag className="h-5 w-5 text-primary" />
LOT
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoItem label="생산품 LOT" value={data.bendingLot.lotNumber} />
<InfoItem label="원자재 재질" value={data.bendingLot.material || '-'} />
<InfoItem label="원자재 LOT" value={data.bendingLot.rawLotNo || '-'} />
{data.bendingLot.prodCode === 'G' && (
<InfoItem label="원단 LOT" value={data.bendingLot.fabricLotNo || '-'} />
)}
</div>
</CardContent>
</Card>
)}
{/* 비고 */}
{(data.memo || data.remarks) && (
<Card>

View File

@@ -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<string, string>;
}
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<BendingCodeMap>({
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<BendingResolvedItem>({
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<BendingLotResult>({
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 };
}