feat(WEB): 자재/영업/품질 모듈 기능 개선 및 문서 컴포넌트 추가
- 입고관리: 상세/목록 UI 개선, actions 로직 강화 - 재고현황: 상세/목록 개선, StockAuditModal 신규 추가 - 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화 - 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가 - 견적: QuoteTransactionModal 기능 개선 - 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선 - UniversalListPage: 템플릿 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,76 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고현황 상세 페이지
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
* API 연동 버전 (2025-12-26)
|
||||
* 재고현황 상세/수정 페이지
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 기본 정보: 재고번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용)
|
||||
* - 수정 가능: 실제 재고량, 안전재고, 상태 (사용/미사용)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { stockStatusConfig } from './stockStatusConfig';
|
||||
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 { getStockById, updateStock } from './actions';
|
||||
import { USE_STATUS_LABELS } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface StockStatusDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 상세 페이지용 데이터 타입
|
||||
interface StockDetailData {
|
||||
id: string;
|
||||
stockNumber: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
calculatedQty: number;
|
||||
actualQty: number;
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialMode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// API 데이터 상태
|
||||
const [detail, setDetail] = useState<StockDetail | null>(null);
|
||||
const [detail, setDetail] = useState<StockDetailData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 폼 데이터 (수정 모드용)
|
||||
const [formData, setFormData] = useState<{
|
||||
actualQty: number;
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
}>({
|
||||
actualQty: 0,
|
||||
safetyStock: 0,
|
||||
useStatus: 'active',
|
||||
});
|
||||
|
||||
// 저장 중 상태
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -53,7 +80,26 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
const result = await getStockById(id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
const data = result.data;
|
||||
// API 응답을 상세 페이지용 데이터로 변환
|
||||
const detailData: StockDetailData = {
|
||||
id: data.id,
|
||||
stockNumber: data.id, // stockNumber가 없으면 id 사용
|
||||
itemCode: data.itemCode,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification || '-',
|
||||
unit: data.unit,
|
||||
calculatedQty: data.currentStock, // 계산 재고량
|
||||
actualQty: data.currentStock, // 실제 재고량 (별도 필드 없으면 currentStock 사용)
|
||||
safetyStock: data.safetyStock,
|
||||
useStatus: data.status === null ? 'active' : 'active', // 기본값
|
||||
};
|
||||
setDetail(detailData);
|
||||
setFormData({
|
||||
actualQty: detailData.actualQty,
|
||||
safetyStock: detailData.safetyStock,
|
||||
useStatus: detailData.useStatus,
|
||||
});
|
||||
} else {
|
||||
setError(result.error || '재고 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
@@ -71,201 +117,185 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
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 handleInputChange = (field: keyof typeof formData, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: field === 'useStatus' ? value : Number(value),
|
||||
}));
|
||||
};
|
||||
|
||||
// 총 수량 계산
|
||||
const totalQty = useMemo(() => {
|
||||
if (!detail) return 0;
|
||||
return detail.lots.reduce((sum, lot) => sum + lot.qty, 0);
|
||||
}, [detail]);
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!detail) return;
|
||||
|
||||
// 커스텀 헤더 액션 (품목코드와 상태 뱃지)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateStock(id, formData);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('재고 정보가 저장되었습니다.');
|
||||
// 상세 데이터 업데이트
|
||||
setDetail((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
actualQty: formData.actualQty,
|
||||
safetyStock: formData.safetyStock,
|
||||
useStatus: formData.useStatus,
|
||||
}
|
||||
: null
|
||||
);
|
||||
// view 모드로 전환
|
||||
router.push(`/ko/material/stock-status/${id}?mode=view`);
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[StockStatusDetail] handleSave error:', err);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 읽기 전용 필드 렌더링 (수정 모드에서 구분용)
|
||||
const renderReadOnlyField = (label: string, value: string | number, isEditMode = false) => (
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">{label}</Label>
|
||||
{isEditMode ? (
|
||||
// 수정 모드: 읽기 전용임을 명확히 표시 (어두운 배경 + cursor-not-allowed)
|
||||
<div className="mt-1.5 px-3 py-2 bg-gray-200 border border-gray-300 rounded-md text-sm text-gray-500 cursor-not-allowed select-none">
|
||||
{value}
|
||||
</div>
|
||||
) : (
|
||||
// 보기 모드: 일반 텍스트 스타일
|
||||
<div className="mt-1.5 px-3 py-2 bg-gray-50 border rounded-md text-sm">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 상세 보기 모드 렌더링
|
||||
const renderViewContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{detail.itemCode}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{STOCK_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</>
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재고번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 단위, 계산 재고량, 실제 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('계산 재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('실제 재고량', detail.actualQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
// 수정 모드 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<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>
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재고번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode, true)}
|
||||
{renderReadOnlyField('품목명', detail.itemName, true)}
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
</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>
|
||||
{/* Row 2: 단위, 계산 재고량 (읽기 전용) + 실제 재고량, 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('계산 재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 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>
|
||||
<Label htmlFor="actualQty" className="text-sm text-muted-foreground">
|
||||
실제 재고량
|
||||
</Label>
|
||||
<Input
|
||||
id="actualQty"
|
||||
type="number"
|
||||
value={formData.actualQty}
|
||||
onChange={(e) => handleInputChange('actualQty', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
|
||||
안전재고
|
||||
</Label>
|
||||
<Input
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
value={formData.safetyStock}
|
||||
onChange={(e) => handleInputChange('safetyStock', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</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}일 경과되었습니다. 우선 사용을 권장합니다.
|
||||
{/* Row 3: 상태 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
|
||||
상태
|
||||
</Label>
|
||||
<Select
|
||||
key={`useStatus-${formData.useStatus}`}
|
||||
value={formData.useStatus}
|
||||
onValueChange={(value) => handleInputChange('useStatus', value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">사용</SelectItem>
|
||||
<SelectItem value="inactive">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}, [detail, totalQty, oldestLot]);
|
||||
}, [detail, formData]);
|
||||
|
||||
// 에러 상태 표시
|
||||
if (!isLoading && (error || !detail)) {
|
||||
@@ -283,13 +313,14 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={stockStatusConfig}
|
||||
mode="view"
|
||||
mode={initialMode as 'view' | 'edit'}
|
||||
initialData={detail || {}}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
isSaving={isSaving}
|
||||
renderView={() => renderViewContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user