Phase 5 완료 (5개 페이지): - 입금관리, 출금관리, 단가관리(건설): Pattern A (기존 V2 컴포넌트 활용) - 판매수주관리, 품목관리: Pattern B (View/Edit 컴포넌트 분리) 신규 컴포넌트: - OrderSalesDetailView.tsx, OrderSalesDetailEdit.tsx - ItemDetailView.tsx, ItemDetailEdit.tsx 기타 정리: - backup 파일 삭제 (Dashboard, ItemMasterDataManagement 등) - 타입 정의 개선 (건설 도메인 types.ts) - useAuthGuard 훅 정리 Co-Authored-By: Claude <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 재고현황 상세 페이지
|
|
* API 연동 버전 (2025-12-26)
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Package, AlertCircle, List } from 'lucide-react';
|
|
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
|
import { getStockById } from './actions';
|
|
import {
|
|
ITEM_TYPE_LABELS,
|
|
ITEM_TYPE_STYLES,
|
|
STOCK_STATUS_LABELS,
|
|
LOT_STATUS_LABELS,
|
|
} from './types';
|
|
import type { StockDetail, LotDetail } from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
interface StockStatusDetailProps {
|
|
id: string;
|
|
}
|
|
|
|
export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
|
const router = useRouter();
|
|
|
|
// API 데이터 상태
|
|
const [detail, setDetail] = useState<StockDetail | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// API 데이터 로드
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await getStockById(id);
|
|
|
|
if (result.success && result.data) {
|
|
setDetail(result.data);
|
|
} else {
|
|
setError(result.error || '재고 정보를 찾을 수 없습니다.');
|
|
}
|
|
} catch (err) {
|
|
if (isNextRedirectError(err)) throw err;
|
|
console.error('[StockStatusDetail] loadData error:', err);
|
|
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
// 데이터 로드
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// 가장 오래된 LOT 찾기 (FIFO 권장용)
|
|
const oldestLot = useMemo(() => {
|
|
if (!detail || detail.lots.length === 0) return null;
|
|
return detail.lots.reduce((oldest, lot) =>
|
|
lot.daysElapsed > oldest.daysElapsed ? lot : oldest
|
|
);
|
|
}, [detail]);
|
|
|
|
// 총 수량 계산
|
|
const totalQty = useMemo(() => {
|
|
if (!detail) return 0;
|
|
return detail.lots.reduce((sum, lot) => sum + lot.qty, 0);
|
|
}, [detail]);
|
|
|
|
// 목록으로 돌아가기
|
|
const handleGoBack = useCallback(() => {
|
|
router.push('/ko/material/stock-status');
|
|
}, [router]);
|
|
|
|
// 로딩 상태 표시
|
|
if (isLoading) {
|
|
return (
|
|
<PageLayout>
|
|
<ContentLoadingSpinner text="재고 정보를 불러오는 중..." />
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// 에러 상태 표시
|
|
if (error || !detail) {
|
|
return (
|
|
<ServerErrorPage
|
|
title="재고 정보를 불러올 수 없습니다"
|
|
message={error || '재고 정보를 찾을 수 없습니다.'}
|
|
onRetry={loadData}
|
|
showBackButton={true}
|
|
showHomeButton={true}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div className="space-y-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Package className="w-6 h-6" />
|
|
<h1 className="text-xl font-semibold">재고 상세</h1>
|
|
<span className="text-muted-foreground">{detail.itemCode}</span>
|
|
<Badge variant="outline" className="text-xs">
|
|
{STOCK_STATUS_LABELS[detail.status]}
|
|
</Badge>
|
|
</div>
|
|
<Button variant="outline" onClick={handleGoBack}>
|
|
<List className="h-4 w-4 mr-2" />
|
|
목록
|
|
</Button>
|
|
</div>
|
|
{/* 기본 정보 */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">품목코드</div>
|
|
<div className="font-medium">{detail.itemCode}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">품목명</div>
|
|
<div className="font-medium">{detail.itemName}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">품목유형</div>
|
|
<Badge className={`text-xs ${ITEM_TYPE_STYLES[detail.itemType]}`}>
|
|
{ITEM_TYPE_LABELS[detail.itemType]}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">카테고리</div>
|
|
<div className="font-medium">{detail.category}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">규격</div>
|
|
<div className="font-medium">{detail.specification || '-'}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">단위</div>
|
|
<div className="font-medium">{detail.unit}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 재고 현황 */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-base font-medium">재고 현황</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">현재 재고량</div>
|
|
<div className="text-2xl font-bold">
|
|
{detail.currentStock} <span className="text-base font-normal">{detail.unit}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">안전 재고</div>
|
|
<div className="text-lg font-medium">
|
|
{detail.safetyStock} <span className="text-sm font-normal">{detail.unit}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">재고 위치</div>
|
|
<div className="font-medium">{detail.location}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">LOT 개수</div>
|
|
<div className="font-medium">{detail.lotCount}개</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">최근 입고일</div>
|
|
<div className="font-medium">{detail.lastReceiptDate}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">재고 상태</div>
|
|
<Badge variant="outline" className="text-xs">
|
|
{STOCK_STATUS_LABELS[detail.status]}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* LOT별 상세 재고 */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base font-medium">LOT별 상세 재고</CardTitle>
|
|
<div className="text-sm text-muted-foreground">
|
|
FIFO 순서 · 오래된 LOT부터 사용 권장
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50">
|
|
<TableHead className="w-[60px] text-center">FIFO</TableHead>
|
|
<TableHead className="min-w-[100px]">LOT번호</TableHead>
|
|
<TableHead className="w-[100px]">입고일</TableHead>
|
|
<TableHead className="w-[70px] text-center">경과일</TableHead>
|
|
<TableHead className="min-w-[100px]">공급업체</TableHead>
|
|
<TableHead className="min-w-[120px]">발주번호</TableHead>
|
|
<TableHead className="w-[80px] text-center">수량</TableHead>
|
|
<TableHead className="w-[60px] text-center">위치</TableHead>
|
|
<TableHead className="w-[80px] text-center">상태</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{detail.lots.map((lot: LotDetail) => (
|
|
<TableRow key={lot.id}>
|
|
<TableCell className="text-center">
|
|
<div className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
|
{lot.fifoOrder}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="font-medium">{lot.lotNo}</TableCell>
|
|
<TableCell>{lot.receiptDate}</TableCell>
|
|
<TableCell className="text-center">
|
|
<span className={lot.daysElapsed > 30 ? 'text-orange-600 font-medium' : ''}>
|
|
{lot.daysElapsed}일
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>{lot.supplier}</TableCell>
|
|
<TableCell>{lot.poNumber}</TableCell>
|
|
<TableCell className="text-center">
|
|
{lot.qty} {lot.unit}
|
|
</TableCell>
|
|
<TableCell className="text-center">{lot.location}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant="outline" className="text-xs">
|
|
{LOT_STATUS_LABELS[lot.status]}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{/* 합계 행 */}
|
|
<TableRow className="bg-muted/30 font-medium">
|
|
<TableCell colSpan={6} className="text-right">
|
|
합계:
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
{totalQty} {detail.unit}
|
|
</TableCell>
|
|
<TableCell colSpan={2} />
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* FIFO 권장 메시지 */}
|
|
{oldestLot && oldestLot.daysElapsed > 30 && (
|
|
<div className="flex items-start gap-2 p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
|
<AlertCircle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
|
|
<div className="text-sm text-orange-800">
|
|
<span className="font-medium">FIFO 권장:</span> LOT {oldestLot.lotNo}가{' '}
|
|
{oldestLot.daysElapsed}일 경과되었습니다. 우선 사용을 권장합니다.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
} |