Files
sam-react-prod/src/components/stocks/StockProductionDetail.tsx

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="이 재고생산을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
/>
</>
);
}