refactor: [stocks] 재고생산 수정 화면을 등록과 동일한 레이아웃으로 변경

- BendingLotForm에 edit mode 추가 (initialData 프리필, update API 호출)
- actions.ts에 updateBendingStockOrder 함수 추가
- [id]/page.tsx에서 StockProductionForm → BendingLotForm으로 전환
- StockProductionForm.tsx 삭제 (수주서 형식 복사본, 더 이상 불필요)
This commit is contained in:
김보곤
2026-03-18 21:27:26 +09:00
parent a57c40fafb
commit 0bf57b1408
4 changed files with 157 additions and 404 deletions

View File

@@ -4,7 +4,7 @@
* 재고생산 상세/수정 페이지
*
* - 기본: 상세 보기 (StockProductionDetail)
* - ?mode=edit: 수정 (StockProductionForm)
* - ?mode=edit: 수정 (BendingLotForm — 등록과 동일 레이아웃)
*/
import { useState, useEffect } from 'react';
@@ -12,7 +12,7 @@ import { useParams, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { StockProductionDetail } from '@/components/stocks/StockProductionDetail';
import { StockProductionForm } from '@/components/stocks/StockProductionForm';
import { BendingLotForm } from '@/components/stocks/BendingLotForm';
import { getStockOrderById, type StockOrder } from '@/components/stocks/actions';
function EditStockContent({ id }: { id: string }) {
@@ -58,7 +58,7 @@ function EditStockContent({ id }: { id: string }) {
);
}
return <StockProductionForm initialData={order} isEditMode />;
return <BendingLotForm initialData={order} isEditMode />;
}
export default function StockDetailPage() {

View File

@@ -38,10 +38,12 @@ import {
resolveBendingItem,
generateBendingLot,
createBendingStockOrder,
updateBendingStockOrder,
getMaterialLots,
type BendingCodeMap,
type BendingResolvedItem,
type MaterialLot,
type StockOrder,
} from './actions';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
@@ -63,6 +65,20 @@ const bendingCreateConfig: DetailConfig = {
},
};
const bendingEditConfig: DetailConfig = {
title: '절곡품 재고생산',
description: '절곡품 재고생산 정보를 수정합니다',
icon: Package,
basePath: '/sales/stocks',
fields: [],
actions: {
showBack: true,
showSave: true,
submitLabel: '저장',
backLabel: '취소',
},
};
// ============================================================================
// LOT 프리뷰 날짜코드 생성
// ============================================================================
@@ -213,13 +229,33 @@ function MaterialLotModal({
// Component
// ============================================================================
export function BendingLotForm() {
interface BendingLotFormProps {
initialData?: StockOrder;
isEditMode?: boolean;
}
export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFormProps) {
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 [form, setForm] = useState<BendingFormState>(() => {
if (initialData?.bendingLot) {
const bl = initialData.bendingLot;
return {
regDate: getInitialForm().regDate,
prodCode: bl.prodCode || '',
specCode: bl.specCode || '',
lengthCode: bl.lengthCode || '',
quantity: initialData.targetStockQty || initialData.quantity || 1,
rawLotNo: bl.rawLotNo || '',
fabricLotNo: bl.fabricLotNo || '',
memo: initialData.memo || '',
};
}
return getInitialForm();
});
const [codeMap, setCodeMap] = useState<BendingCodeMap | null>(null);
const [resolvedItem, setResolvedItem] = useState<BendingResolvedItem | null>(null);
const [resolveError, setResolveError] = useState<string>('');
@@ -228,7 +264,9 @@ export function BendingLotForm() {
const [rawLotModalOpen, setRawLotModalOpen] = useState(false);
const [fabricLotModalOpen, setFabricLotModalOpen] = useState(false);
// 코드맵 로드
const config = isEditMode ? bendingEditConfig : bendingCreateConfig;
// 코드맵 로드 + edit mode 시 초기 품목 매핑 조회
useEffect(() => {
async function loadCodeMap() {
const result = await getBendingCodeMap();
@@ -238,6 +276,17 @@ export function BendingLotForm() {
}
if (result.success && result.data) {
setCodeMap(result.data);
// edit mode: 초기 데이터의 품목 매핑 자동 조회
if (initialData?.bendingLot) {
const bl = initialData.bendingLot;
if (bl.prodCode && bl.specCode && bl.lengthCode) {
const itemResult = await resolveBendingItem(bl.prodCode, bl.specCode, bl.lengthCode);
if (itemResult.success && itemResult.data) {
setResolvedItem(itemResult.data);
}
}
}
} else {
toast.error(result.error || '코드맵 로딩에 실패했습니다.');
}
@@ -363,12 +412,12 @@ export function BendingLotForm() {
const lotData = lotResult.data;
// 2. 재고생산 저장
// 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({
const saveParams = {
memo: form.memo,
targetStockQty: form.quantity,
bendingLot: {
@@ -388,7 +437,11 @@ export function BendingLotForm() {
quantity: form.quantity,
unit: resolvedItem?.unit || 'EA',
},
});
};
const saveResult = isEditMode && initialData
? await updateBendingStockOrder(initialData.id, saveParams)
: await createBendingStockOrder(saveParams);
if (saveResult.__authError) {
toast.error('인증이 만료되었습니다.');
@@ -399,7 +452,7 @@ export function BendingLotForm() {
return;
}
toast.success('절곡품 재고생산이 등록되었습니다.');
toast.success(isEditMode ? '절곡품 재고생산이 수정되었습니다.' : '절곡품 재고생산이 등록되었습니다.');
if (saveResult.data?.id) {
router.push(`${basePath}/${saveResult.data.id}`);
} else {
@@ -412,8 +465,12 @@ export function BendingLotForm() {
// 취소
const handleCancel = useCallback(() => {
router.push(basePath);
}, [router, basePath]);
if (isEditMode && initialData) {
router.push(`${basePath}/${initialData.id}`);
} else {
router.push(basePath);
}
}, [isEditMode, initialData, router, basePath]);
// renderForm
const renderFormContent = useMemo(
@@ -550,15 +607,19 @@ export function BendingLotForm() {
<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>
<Label>{isEditMode ? '생산품 LOT' : '생산품 LOT (프리뷰)'}</Label>
<Input
value={lotPreview ? `${lotPreview}-___` : ''}
value={isEditMode && initialData?.bendingLot?.lotNumber
? initialData.bendingLot.lotNumber
: lotPreview ? `${lotPreview}-___` : ''}
disabled
placeholder="품목/종류/등록일 선택 시 자동 생성"
/>
<p className="text-xs text-muted-foreground">
</p>
{!isEditMode && (
<p className="text-xs text-muted-foreground">
</p>
)}
</div>
<div className="space-y-2">
<Label> </Label>
@@ -661,6 +722,8 @@ export function BendingLotForm() {
resolvedItem,
resolveError,
isSmokeBarrier,
isEditMode,
initialData,
handleProdChange,
handleSpecChange,
handleLengthChange,
@@ -670,8 +733,8 @@ export function BendingLotForm() {
return (
<>
<IntegratedDetailTemplate
config={bendingCreateConfig}
mode="create"
config={config}
mode={isEditMode ? 'edit' : 'create'}
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={handleSave}

View File

@@ -1,385 +0,0 @@
'use client';
/**
* 재고생산 등록/수정 폼
*
* - 생산사유, 목표재고수량, 메모, 비고
* - 품목 내역 (자동 추가, 수동 추가 불가)
* - IntegratedDetailTemplate + renderForm 패턴
*/
import { useState, useCallback, useMemo } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { QuantityInput } from '@/components/ui/quantity-input';
import { NumberInput } from '@/components/ui/number-input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Package, Trash2, MessageSquare, ClipboardList } from 'lucide-react';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { FormSection } from '@/components/organisms/FormSection';
import type { OrderItem } from '@/components/orders/ItemAddDialog';
import { formatAmount } from '@/lib/utils/amount';
import {
createStockOrder,
updateStockOrder,
type StockOrder,
type StockOrderFormData,
type StockOrderItemFormData,
} from './actions';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
// ============================================================================
// Config
// ============================================================================
const stockCreateConfig: DetailConfig = {
title: '재고생산',
description: '재고생산을 등록합니다',
icon: Package,
basePath: '/sales/stocks',
fields: [],
actions: {
showBack: true,
showSave: true,
submitLabel: '저장',
backLabel: '취소',
},
};
const stockEditConfig: DetailConfig = {
title: '재고생산',
description: '재고생산 정보를 수정합니다',
icon: Package,
basePath: '/sales/stocks',
fields: [],
actions: {
showBack: true,
showSave: true,
submitLabel: '저장',
backLabel: '취소',
},
};
// ============================================================================
// 폼 데이터
// ============================================================================
interface StockFormData {
productionReason: string;
targetStockQty: string;
memo: string;
remarks: string;
items: OrderItem[];
}
const INITIAL_FORM: StockFormData = {
productionReason: '',
targetStockQty: '',
memo: '',
remarks: '',
items: [],
};
// ============================================================================
// Props
// ============================================================================
interface StockProductionFormProps {
initialData?: StockOrder;
isEditMode?: boolean;
}
// ============================================================================
// Component
// ============================================================================
export function StockProductionForm({
initialData,
isEditMode = false,
}: StockProductionFormProps) {
const router = useRouter();
const params = useParams();
const locale = (params.locale as string) || 'ko';
const basePath = `/${locale}/sales/stocks`;
const config = isEditMode ? stockEditConfig : stockCreateConfig;
// 초기 데이터 변환
const [form, setForm] = useState<StockFormData>(() => {
if (initialData) {
return {
productionReason: initialData.productionReason || '',
targetStockQty: initialData.targetStockQty ? String(initialData.targetStockQty) : '',
memo: initialData.memo || '',
remarks: initialData.remarks || '',
items: initialData.items.map((item) => ({
id: item.id,
itemId: item.itemId,
itemCode: item.itemCode,
itemName: item.itemName,
specification: item.specification,
quantity: item.quantity,
unit: item.unit,
unitPrice: item.unitPrice,
amount: item.totalAmount,
})),
};
}
return INITIAL_FORM;
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
// 필드 에러 초기화
const clearFieldError = useCallback((field: string) => {
setFieldErrors((prev) => {
if (prev[field]) {
const { [field]: _, ...rest } = prev;
return rest;
}
return prev;
});
}, []);
// 품목 삭제
const handleRemoveItem = useCallback((itemId: string) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((item) => item.id !== itemId),
}));
}, []);
// 품목 수량 변경
const handleQuantityChange = useCallback((itemId: string, quantity: number) => {
setForm((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === itemId
? { ...item, quantity, amount: item.unitPrice * quantity }
: item
),
}));
}, []);
// 유효성 검사
const validate = useCallback((): boolean => {
const errors: Record<string, string> = {};
if (form.items.length === 0) {
errors.items = '품목을 1개 이상 추가해주세요';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
}, [form.items]);
// 저장
const handleSave = useCallback(async () => {
if (!validate()) {
toast.error('입력 정보를 확인해주세요.');
return;
}
const formData: StockOrderFormData = {
orderTypeCode: 'STOCK',
memo: form.memo,
remarks: form.remarks,
productionReason: form.productionReason,
targetStockQty: Number(form.targetStockQty) || 0,
items: form.items.map((item): StockOrderItemFormData => ({
itemId: item.itemId,
itemCode: item.itemCode,
itemName: item.itemName,
specification: item.specification,
quantity: item.quantity,
unit: item.unit || 'EA',
unitPrice: item.unitPrice,
})),
};
const result = isEditMode && initialData
? await updateStockOrder(initialData.id, formData)
: await createStockOrder(formData);
if (result.__authError) {
toast.error('인증이 만료되었습니다. 다시 로그인해주세요.');
return;
}
if (!result.success) {
toast.error(result.error || '저장에 실패했습니다.');
return;
}
toast.success(isEditMode ? '재고생산이 수정되었습니다.' : '재고생산이 등록되었습니다.');
if (result.data?.id) {
router.push(`${basePath}/${result.data.id}`);
} else {
router.push(basePath);
}
}, [form, isEditMode, initialData, validate, router, basePath]);
// 취소
const handleCancel = useCallback(() => {
if (isEditMode && initialData) {
router.push(`${basePath}/${initialData.id}`);
} else {
router.push(basePath);
}
}, [isEditMode, initialData, 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 htmlFor="productionReason"></Label>
<Input
id="productionReason"
placeholder="생산사유를 입력하세요"
value={form.productionReason}
onChange={(e) => setForm((prev) => ({ ...prev, productionReason: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="targetStockQty"></Label>
<NumberInput
id="targetStockQty"
placeholder="0"
value={Number(form.targetStockQty) || 0}
onChange={(value) => setForm((prev) => ({ ...prev, targetStockQty: String(value ?? 0) }))}
min={0}
/>
</div>
</div>
</FormSection>
{/* 비고 */}
<FormSection title="비고" icon={MessageSquare}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="memo"></Label>
<Textarea
id="memo"
placeholder="메모를 입력하세요"
value={form.memo}
onChange={(e) => setForm((prev) => ({ ...prev, memo: e.target.value }))}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remarks"></Label>
<Textarea
id="remarks"
placeholder="비고를 입력하세요"
value={form.remarks}
onChange={(e) => setForm((prev) => ({ ...prev, remarks: e.target.value }))}
rows={3}
/>
</div>
</div>
</FormSection>
{/* 품목 내역 */}
<FormSection
title="품목 내역"
icon={Package}
>
{fieldErrors.items && (
<p className="text-sm text-red-500 mb-3">{fieldErrors.items}</p>
)}
{form.items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p> . .</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-center"></TableHead>
<TableHead className="w-20 text-center"></TableHead>
<TableHead className="w-28 text-right"></TableHead>
<TableHead className="w-28 text-right"></TableHead>
<TableHead className="w-16 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{form.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.specification || item.spec || '-'}
</TableCell>
<TableCell>
<QuantityInput
value={item.quantity}
onChange={(value) => handleQuantityChange(item.id, value ?? 1)}
min={1}
className="w-20"
/>
</TableCell>
<TableCell className="text-center">
{item.unit || 'EA'}
</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice)}
</TableCell>
<TableCell className="text-right">
{formatAmount((item.amount ?? item.unitPrice * item.quantity))}
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700"
onClick={() => handleRemoveItem(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</FormSection>
</div>
),
[form, fieldErrors, handleQuantityChange, handleRemoveItem]
);
return (
<IntegratedDetailTemplate
config={config}
mode={isEditMode ? 'edit' : 'create'}
isLoading={false}
onCancel={handleCancel}
onSubmit={() => handleSave()}
renderForm={renderFormContent}
/>
);
}

View File

@@ -724,6 +724,81 @@ export async function createBendingStockOrder(params: {
return { success: result.success, data: result.data, error: result.error };
}
/**
* 절곡품 재고생산 수정 (기존 orders API + bending_lot 확장)
*/
export async function updateBendingStockOrder(id: string, 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/${id}`),
method: 'PUT',
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 };
}
/**
* 원자재 LOT 목록 조회 (수입검사 완료 입고 건)
*/