refactor: [stocks] 재고생산 상세 보기를 등록 화면과 동일한 레이아웃으로 변경
- Card+InfoItem 방식 → FormSection+Input(disabled) 레이아웃 - 기본 정보, 품목 선택, LOT 정보, 메모 섹션 구조 통일 - 코드맵 로드하여 품목명/종류/모양&길이 한글 표시 - 매핑된 품목 정보 green box 표시 (등록 화면과 동일)
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user