442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 재고생산 상세 보기 컴포넌트
|
|
*
|
|
* - 기본 정보 (생산번호, 상태, 생산사유, 목표재고수량, 메모, 비고)
|
|
* - 품목 내역 테이블
|
|
* - 상태 변경 / 수정 / 생산지시 생성 버튼
|
|
*/
|
|
|
|
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 {
|
|
Package,
|
|
Pencil,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Factory,
|
|
ClipboardList,
|
|
MessageSquare,
|
|
Tag,
|
|
RotateCcw,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { BadgeSm } from '@/components/atoms/BadgeSm';
|
|
import { formatAmount } from '@/lib/utils/amount';
|
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import {
|
|
getStockOrderById,
|
|
updateStockOrderStatus,
|
|
deleteStockOrder,
|
|
createStockProductionOrder,
|
|
type StockOrder,
|
|
type StockStatus,
|
|
} from './actions';
|
|
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
|
import type { ActionItem } from '@/components/templates/IntegratedDetailTemplate/types';
|
|
|
|
// ============================================================================
|
|
// Config
|
|
// ============================================================================
|
|
|
|
const stockDetailConfig: DetailConfig = {
|
|
title: '재고생산',
|
|
description: '재고생산 정보를 조회합니다',
|
|
icon: Package,
|
|
basePath: '/sales/stocks',
|
|
fields: [],
|
|
actions: {
|
|
showBack: true,
|
|
showEdit: false,
|
|
showDelete: false,
|
|
backLabel: '목록',
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// 상태 뱃지
|
|
// ============================================================================
|
|
|
|
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
|
draft: { label: '등록', className: 'bg-gray-100 text-gray-700 border-gray-200' },
|
|
confirmed: { label: '확정', className: 'bg-blue-100 text-blue-700 border-blue-200' },
|
|
in_progress: { label: '진행중', className: 'bg-green-100 text-green-700 border-green-200' },
|
|
in_production: { label: '생산중', className: 'bg-green-100 text-green-700 border-green-200' },
|
|
produced: { label: '생산완료', className: 'bg-blue-600 text-white border-blue-600' },
|
|
completed: { label: '완료', className: 'bg-gray-500 text-white border-gray-500' },
|
|
cancelled: { label: '취소', className: 'bg-red-100 text-red-700 border-red-200' },
|
|
};
|
|
|
|
function getStatusBadge(status: string) {
|
|
const config = STATUS_CONFIG[status] || { label: status, className: 'bg-gray-100 text-gray-700 border-gray-200' };
|
|
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
|
|
// ============================================================================
|
|
|
|
interface StockProductionDetailProps {
|
|
orderId: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Component
|
|
// ============================================================================
|
|
|
|
export function StockProductionDetail({ orderId }: StockProductionDetailProps) {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const locale = (params.locale as string) || 'ko';
|
|
const basePath = `/${locale}/sales/stocks`;
|
|
|
|
const [order, setOrder] = useState<StockOrder | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
// 데이터 로드
|
|
useEffect(() => {
|
|
async function loadOrder() {
|
|
try {
|
|
setLoading(true);
|
|
const result = await getStockOrderById(orderId);
|
|
if (result.__authError) {
|
|
toast.error('인증이 만료되었습니다.');
|
|
return;
|
|
}
|
|
if (result.success && result.data) {
|
|
setOrder(result.data);
|
|
} else {
|
|
toast.error(result.error || '재고생산 정보를 불러오는데 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
loadOrder();
|
|
}, [orderId]);
|
|
|
|
// 상태 변경
|
|
const handleStatusChange = useCallback(async (newStatus: StockStatus) => {
|
|
if (!order) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
const result = await updateStockOrderStatus(order.id, newStatus);
|
|
if (result.__authError) {
|
|
toast.error('인증이 만료되었습니다.');
|
|
return;
|
|
}
|
|
if (result.success && result.data) {
|
|
setOrder(result.data);
|
|
toast.success('상태가 변경되었습니다.');
|
|
} else {
|
|
toast.error(result.error || '상태 변경에 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
}, [order]);
|
|
|
|
// 삭제
|
|
const handleDelete = useCallback(async () => {
|
|
if (!order) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
const result = await deleteStockOrder(order.id);
|
|
if (result.__authError) {
|
|
toast.error('인증이 만료되었습니다.');
|
|
return;
|
|
}
|
|
if (result.success) {
|
|
toast.success('재고생산이 삭제되었습니다.');
|
|
router.push(basePath);
|
|
} else {
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setIsProcessing(false);
|
|
setIsDeleteDialogOpen(false);
|
|
}
|
|
}, [order, router, basePath]);
|
|
|
|
// 생산지시 생성
|
|
const handleCreateProductionOrder = useCallback(async () => {
|
|
if (!order) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
const result = await createStockProductionOrder(order.id);
|
|
if (result.__authError) {
|
|
toast.error('인증이 만료되었습니다.');
|
|
return;
|
|
}
|
|
if (result.success) {
|
|
toast.success('생산지시가 생성되었습니다.');
|
|
// 상태 갱신
|
|
const refreshResult = await getStockOrderById(orderId);
|
|
if (refreshResult.success && refreshResult.data) {
|
|
setOrder(refreshResult.data);
|
|
}
|
|
} else {
|
|
toast.error(result.error || '생산지시 생성에 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
}, [order, orderId]);
|
|
|
|
// 수정 이동
|
|
const handleEdit = useCallback(() => {
|
|
router.push(`${basePath}/${orderId}?mode=edit`);
|
|
}, [router, basePath, orderId]);
|
|
|
|
// 헤더 액션 버튼
|
|
const headerActionItems = useMemo((): ActionItem[] => {
|
|
if (!order) return [];
|
|
const items: ActionItem[] = [];
|
|
|
|
// draft → 확정 가능
|
|
if (order.status === 'draft') {
|
|
items.push({
|
|
icon: CheckCircle2,
|
|
label: '확정',
|
|
onClick: () => handleStatusChange('confirmed'),
|
|
className: 'bg-blue-600 hover:bg-blue-700 text-white',
|
|
disabled: isProcessing,
|
|
});
|
|
items.push({
|
|
icon: Pencil,
|
|
label: '수정',
|
|
onClick: handleEdit,
|
|
variant: 'outline',
|
|
});
|
|
}
|
|
|
|
// confirmed → 생산지시 생성 + 수정 불가 안내
|
|
if (order.status === 'confirmed') {
|
|
items.push({
|
|
icon: Factory,
|
|
label: '생산지시 생성',
|
|
onClick: handleCreateProductionOrder,
|
|
className: 'bg-green-600 hover:bg-green-500 text-white',
|
|
disabled: isProcessing,
|
|
});
|
|
items.push({
|
|
icon: Pencil,
|
|
label: '수정',
|
|
onClick: () => toast.warning('확정 상태에서는 수정이 불가합니다.'),
|
|
variant: 'outline',
|
|
disabled: false,
|
|
className: 'opacity-50',
|
|
});
|
|
}
|
|
|
|
// draft/confirmed → 취소 가능
|
|
if (order.status === 'draft' || order.status === 'confirmed') {
|
|
items.push({
|
|
icon: XCircle,
|
|
label: '취소',
|
|
onClick: () => handleStatusChange('cancelled'),
|
|
variant: 'destructive',
|
|
disabled: isProcessing,
|
|
});
|
|
}
|
|
|
|
// cancelled → 복원 버튼
|
|
if (order.status === 'cancelled') {
|
|
items.push({
|
|
icon: RotateCcw,
|
|
label: '복원',
|
|
onClick: () => handleStatusChange('draft'),
|
|
className: 'bg-blue-600 hover:bg-blue-500 text-white',
|
|
disabled: isProcessing,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}, [order, isProcessing, handleStatusChange, handleEdit, handleCreateProductionOrder]);
|
|
|
|
// 상태 뱃지 — 기본 정보 카드에 이미 표시되므로 하단 바에는 미표시
|
|
const headerActions = useMemo(() => {
|
|
return null;
|
|
}, []);
|
|
|
|
// renderView
|
|
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} />
|
|
</div>
|
|
</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>
|
|
<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>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
),
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<IntegratedDetailTemplate
|
|
config={stockDetailConfig}
|
|
mode="view"
|
|
initialData={order as unknown as Record<string, unknown>}
|
|
itemId={orderId}
|
|
isLoading={loading}
|
|
onCancel={() => router.push(basePath)}
|
|
headerActions={headerActions}
|
|
headerActionItems={headerActionItems}
|
|
renderView={(data) => renderViewContent(data as unknown as StockOrder)}
|
|
/>
|
|
|
|
<DeleteConfirmDialog
|
|
open={isDeleteDialogOpen}
|
|
onOpenChange={setIsDeleteDialogOpen}
|
|
onConfirm={handleDelete}
|
|
title="재고생산 삭제"
|
|
description="이 재고생산을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
|
|
/>
|
|
</>
|
|
);
|
|
}
|