refactor: [stocks] 재고생산 상세 보기를 등록 화면과 동일한 레이아웃으로 변경

- Card+InfoItem 방식 → FormSection+Input(disabled) 레이아웃
- 기본 정보, 품목 선택, LOT 정보, 메모 섹션 구조 통일
- 코드맵 로드하여 품목명/종류/모양&길이 한글 표시
- 매핑된 품목 정보 green box 표시 (등록 화면과 동일)
This commit is contained in:
김보곤
2026-03-18 21:34:37 +09:00
parent 969cbdbd3c
commit b840ebba35

View File

@@ -3,41 +3,36 @@
/**
* 재고생산 상세 보기 컴포넌트
*
* - 기본 정보 (생산번호, 상태, 생산사유, 목표재고수량, 메모, 비고)
* - 품목 내역 테이블
* - 상태 변경 / 수정 / 생산지시 생성 버튼
* - BendingLotForm(등록/수정)과 동일한 레이아웃 (읽기 전용)
* - 기본 정보, 품목 선택, LOT 정보, 메모 섹션
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Package,
ClipboardList,
MessageSquare,
Tag,
Layers,
RotateCcw,
} from 'lucide-react';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { FormSection } from '@/components/organisms/FormSection';
import { BadgeSm } from '@/components/atoms/BadgeSm';
import { formatAmount } from '@/lib/utils/amount';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
getStockOrderById,
getBendingCodeMap,
updateStockOrderStatus,
deleteStockOrder,
type StockOrder,
type StockStatus,
type BendingCodeMap,
} from './actions';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
import type { ActionItem } from '@/components/templates/IntegratedDetailTemplate/types';
@@ -47,7 +42,7 @@ import type { ActionItem } from '@/components/templates/IntegratedDetailTemplate
// ============================================================================
const stockDetailConfig: DetailConfig = {
title: '재고생산',
title: '절곡품 재고생산',
description: '재고생산 정보를 조회합니다',
icon: Package,
basePath: '/sales/stocks',
@@ -79,16 +74,6 @@ function getStatusBadge(status: string) {
return <BadgeSm className={config.className}>{config.label}</BadgeSm>;
}
// 정보 표시 컴포넌트
function InfoItem({ label, value }: { label: string; value: string | number }) {
return (
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="font-medium">{value || '-'}</p>
</div>
);
}
// ============================================================================
// Props
// ============================================================================
@@ -108,32 +93,63 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) {
const basePath = `/${locale}/sales/stocks`;
const [order, setOrder] = useState<StockOrder | null>(null);
const [codeMap, setCodeMap] = useState<BendingCodeMap | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// 데이터 로드
useEffect(() => {
async function loadOrder() {
async function load() {
try {
setLoading(true);
const result = await getStockOrderById(orderId);
if (result.__authError) {
const [orderResult, codeMapResult] = await Promise.all([
getStockOrderById(orderId),
getBendingCodeMap(),
]);
if (orderResult.__authError) {
toast.error('인증이 만료되었습니다.');
return;
}
if (result.success && result.data) {
setOrder(result.data);
if (orderResult.success && orderResult.data) {
setOrder(orderResult.data);
} else {
toast.error(result.error || '재고생산 정보를 불러오는데 실패했습니다.');
toast.error(orderResult.error || '재고생산 정보를 불러오는데 실패했습니다.');
}
if (codeMapResult.success && codeMapResult.data) {
setCodeMap(codeMapResult.data);
}
} finally {
setLoading(false);
}
}
loadOrder();
load();
}, [orderId]);
// 코드 → 이름 변환
const prodName = useMemo(() => {
if (!codeMap || !order?.bendingLot?.prodCode) return order?.bendingLot?.prodCode || '-';
return codeMap.products.find((p) => p.code === order.bendingLot!.prodCode)?.name || order.bendingLot.prodCode;
}, [codeMap, order]);
const specName = useMemo(() => {
if (!codeMap || !order?.bendingLot?.specCode) return order?.bendingLot?.specCode || '-';
return codeMap.specs.find((s) => s.code === order.bendingLot!.specCode)?.name || order.bendingLot.specCode;
}, [codeMap, order]);
const lengthName = useMemo(() => {
if (!codeMap || !order?.bendingLot?.prodCode || !order?.bendingLot?.lengthCode) return order?.bendingLot?.lengthCode || '-';
const lengths = order.bendingLot.prodCode === 'G'
? codeMap.lengths.smoke_barrier
: codeMap.lengths.general;
return lengths.find((l) => l.code === order.bendingLot!.lengthCode)?.name || order.bendingLot.lengthCode;
}, [codeMap, order]);
// 연기차단재 여부
const isSmokeBarrier = order?.bendingLot?.prodCode === 'G';
// 상태 변경
const handleStatusChange = useCallback(async (newStatus: StockStatus) => {
if (!order) return;
@@ -178,12 +194,10 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) {
}, [order, router, basePath]);
// 헤더 액션 버튼
// 재고생산: 저장 즉시 IN_PROGRESS (확정/생산지시 자동). draft/confirmed 분기 불필요.
const headerActionItems = useMemo((): ActionItem[] => {
if (!order) return [];
const items: ActionItem[] = [];
// cancelled → 복원 버튼
if (order.status === 'cancelled') {
items.push({
icon: RotateCcw,
@@ -197,141 +211,127 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) {
return items;
}, [order, isProcessing, handleStatusChange]);
// 상태 뱃지 — 기본 정보 카드에 이미 표시되므로 하단 바에는 미표시
const headerActions = useMemo(() => {
return null;
}, []);
// 품목 정보 (첫 번째 아이템)
const item = order?.items?.[0];
// renderView
// renderView — 등록 화면(BendingLotForm)과 동일한 레이아웃
const renderViewContent = useMemo(
() =>
(data: StockOrder) => (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="h-5 w-5 text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoItem label="생산번호" value={data.orderNo} />
<div>
<p className="text-sm text-muted-foreground"></p>
<div className="mt-1">{getStatusBadge(data.status)}</div>
</div>
<InfoItem label="생산사유" value={data.productionReason} />
<InfoItem label="목표재고수량" value={data.targetStockQty ? String(data.targetStockQty) : '-'} />
<InfoItem label="등록일" value={data.createdAt} />
<InfoItem label="담당자" value={data.manager} />
<FormSection title="기본 정보" icon={ClipboardList}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={data.orderNo} disabled />
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label></Label>
<div className="h-10 flex items-center">
{getStatusBadge(data.status)}
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.createdAt} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={String(data.targetStockQty || data.quantity || '-')} disabled />
</div>
</div>
</FormSection>
{/* 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 || '-'} />
)}
<FormSection title="품목 선택" icon={Layers}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={prodName} disabled />
</div>
</CardContent>
</Card>
)}
<div className="space-y-2">
<Label></Label>
<Input value={specName} disabled />
</div>
<div className="space-y-2">
<Label>&</Label>
<Input value={lengthName} disabled />
</div>
</div>
{/* 비고 */}
{(data.memo || data.remarks) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquare className="h-5 w-5 text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.memo && <InfoItem label="메모" value={data.memo} />}
{data.remarks && <InfoItem label="비고" value={data.remarks} />}
</div>
</CardContent>
</Card>
)}
{/* 품목 내역 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-5 w-5 text-primary" />
({data.items.length})
</CardTitle>
</CardHeader>
<CardContent>
{data.items.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
{item.itemCode ? (
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode}
</code>
) : '-'}
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.specification || '-'}
</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.totalAmount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 매핑된 품목 표시 */}
{item && item.itemCode && (
<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">{item.itemCode}</code>
</div>
<div>
<span className="text-muted-foreground">: </span>
{item.itemName}
</div>
<div>
<span className="text-muted-foreground">: </span>
{item.specification || '-'}
</div>
<div>
<span className="text-muted-foreground">: </span>
{item.unit}
</div>
</div>
</div>
)}
</CardContent>
</Card>
</FormSection>
)}
{/* LOT 정보 */}
{data.bendingLot && (
<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={data.bendingLot.lotNumber || '-'} disabled />
</div>
<div className="space-y-2">
<Label> </Label>
<Input value={data.bendingLot.material || '-'} disabled />
</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 value={data.bendingLot.rawLotNo || '-'} disabled />
</div>
{isSmokeBarrier && (
<div className="space-y-2">
<Label> LOT</Label>
<Input value={data.bendingLot.fabricLotNo || '-'} disabled />
</div>
)}
</div>
</FormSection>
)}
{/* 메모 */}
<FormSection title="메모" icon={MessageSquare}>
<div className="space-y-2">
<Label></Label>
<Textarea
value={data.memo || ''}
disabled
placeholder="메모 없음"
rows={3}
/>
</div>
</FormSection>
</div>
),
[]
[prodName, specName, lengthName, isSmokeBarrier, item]
);
return (
@@ -343,7 +343,7 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) {
itemId={orderId}
isLoading={loading}
onCancel={() => router.push(basePath)}
headerActions={headerActions}
headerActions={null}
headerActionItems={headerActionItems}
renderView={(data) => renderViewContent(data as unknown as StockOrder)}
/>