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:
유병철
2026-01-28 21:15:25 +09:00
parent 79b39a3ef6
commit 1f640622e0
23 changed files with 4683 additions and 1946 deletions

View File

@@ -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}
/>
);
}
}