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,54 +1,94 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고 상세 페이지
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
* 입고 상세/등록/수정 페이지
|
||||
* 기획서 2026-01-28 기준 마이그레이션
|
||||
*
|
||||
* 상태에 따라 다른 UI 표시:
|
||||
* - 검사대기: 입고증, 목록, 검사등록 버튼
|
||||
* - 배송중/발주완료: 목록, 입고처리 버튼 (입고증 없음)
|
||||
* - 입고대기: 목록 버튼만 (입고증 없음)
|
||||
* - 입고완료: 입고증, 목록 버튼
|
||||
* mode 패턴:
|
||||
* - view: 상세 조회 (읽기 전용)
|
||||
* - edit: 수정 모드
|
||||
* - new: 신규 등록 모드
|
||||
*
|
||||
* 섹션:
|
||||
* 1. 기본 정보 - 로트번호, 품목코드, 품목명, 규격, 단위, 발주처, 입고수량, 입고일, 작성자, 상태, 비고
|
||||
* 2. 수입검사 정보 - 검사일, 검사결과, 업체 제공 성적서 자료
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, ClipboardCheck, Download } from 'lucide-react';
|
||||
import { Upload, FileText } from 'lucide-react';
|
||||
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { receivingConfig } from './receivingConfig';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import { getReceivingById, processReceiving } from './actions';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import type { ReceivingDetail as ReceivingDetailType, ReceivingProcessFormData } from './types';
|
||||
import { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
import { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
import { getReceivingById, createReceiving, updateReceiving } from './actions';
|
||||
import {
|
||||
RECEIVING_STATUS_OPTIONS,
|
||||
type ReceivingDetail as ReceivingDetailType,
|
||||
type ReceivingStatus,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
mode?: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
export function ReceivingDetail({ id }: Props) {
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
|
||||
lotNo: '',
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
specification: '',
|
||||
unit: 'EA',
|
||||
supplier: '',
|
||||
receivingQty: undefined,
|
||||
receivingDate: '',
|
||||
createdBy: '',
|
||||
status: 'receiving_pending',
|
||||
remark: '',
|
||||
inspectionDate: '',
|
||||
inspectionResult: '',
|
||||
certificateFile: undefined,
|
||||
};
|
||||
|
||||
export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
const router = useRouter();
|
||||
const [isReceivingProcessDialogOpen, setIsReceivingProcessDialogOpen] = useState(false);
|
||||
const [isReceiptDialogOpen, setIsReceiptDialogOpen] = useState(false);
|
||||
const [successDialog, setSuccessDialog] = useState<{
|
||||
open: boolean;
|
||||
type: 'inspection' | 'receiving';
|
||||
lotNo?: string;
|
||||
}>({ open: false, type: 'receiving' });
|
||||
const isNewMode = mode === 'new' || id === 'new';
|
||||
const isEditMode = mode === 'edit';
|
||||
const isViewMode = mode === 'view' && !isNewMode;
|
||||
|
||||
// API 데이터 상태
|
||||
const [detail, setDetail] = useState<ReceivingDetailType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 폼 데이터 (등록/수정 모드용)
|
||||
const [formData, setFormData] = useState<Partial<ReceivingDetailType>>(INITIAL_FORM_DATA);
|
||||
|
||||
// 수입검사 성적서 모달 상태
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (isNewMode) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -57,6 +97,25 @@ export function ReceivingDetail({ id }: Props) {
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
// 수정 모드일 때 폼 데이터 설정
|
||||
if (isEditMode) {
|
||||
setFormData({
|
||||
lotNo: result.data.lotNo || '',
|
||||
itemCode: result.data.itemCode,
|
||||
itemName: result.data.itemName,
|
||||
specification: result.data.specification || '',
|
||||
unit: result.data.unit || 'EA',
|
||||
supplier: result.data.supplier,
|
||||
receivingQty: result.data.receivingQty,
|
||||
receivingDate: result.data.receivingDate || '',
|
||||
createdBy: result.data.createdBy || '',
|
||||
status: result.data.status,
|
||||
remark: result.data.remark || '',
|
||||
inspectionDate: result.data.inspectionDate || '',
|
||||
inspectionResult: result.data.inspectionResult || '',
|
||||
certificateFile: result.data.certificateFile,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '입고 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
@@ -67,200 +126,307 @@ export function ReceivingDetail({ id }: Props) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
}, [id, isNewMode, isEditMode]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleGoBack = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ReceivingDetailType, value: string | number | undefined) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// 입고증 다이얼로그 열기
|
||||
const handleOpenReceipt = useCallback(() => {
|
||||
setIsReceiptDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 검사등록 페이지로 이동
|
||||
const handleGoToInspection = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management/inspection');
|
||||
}, [router]);
|
||||
|
||||
// 입고처리 다이얼로그 열기
|
||||
const handleOpenReceivingProcessDialog = useCallback(() => {
|
||||
setIsReceivingProcessDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 입고 완료 처리 (API 호출)
|
||||
const handleReceivingComplete = useCallback(async (formData: ReceivingProcessFormData) => {
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await processReceiving(id, formData);
|
||||
|
||||
if (result.success) {
|
||||
setIsReceivingProcessDialogOpen(false);
|
||||
setSuccessDialog({ open: true, type: 'receiving', lotNo: formData.receivingLot });
|
||||
} else {
|
||||
alert(result.error || '입고처리에 실패했습니다.');
|
||||
if (isNewMode) {
|
||||
const result = await createReceiving(formData);
|
||||
if (result.success) {
|
||||
toast.success('입고가 등록되었습니다.');
|
||||
router.push('/ko/material/receiving-management');
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} else if (isEditMode) {
|
||||
const result = await updateReceiving(id, formData);
|
||||
if (result.success) {
|
||||
toast.success('입고 정보가 수정되었습니다.');
|
||||
router.push(`/ko/material/receiving-management/${id}?mode=view`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ReceivingDetail] handleReceivingComplete error:', err);
|
||||
alert('입고처리 중 오류가 발생했습니다.');
|
||||
console.error('[ReceivingDetail] handleSave error:', err);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [id]);
|
||||
};
|
||||
|
||||
// 성공 다이얼로그 닫기
|
||||
const handleSuccessDialogClose = useCallback(() => {
|
||||
setSuccessDialog({ open: false, type: 'receiving' });
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
// 수입검사하기 버튼 핸들러 - 모달로 표시
|
||||
const handleInspection = () => {
|
||||
setIsInspectionModalOpen(true);
|
||||
};
|
||||
|
||||
// 커스텀 헤더 액션 (상태별 버튼들)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
|
||||
const handleCancel = () => {
|
||||
if (isNewMode) {
|
||||
router.push('/ko/material/receiving-management');
|
||||
} else {
|
||||
router.push(`/ko/material/receiving-management/${id}?mode=view`);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 읽기 전용 필드 렌더링 =====
|
||||
const renderReadOnlyField = (label: string, value: string | number | undefined, isEditModeStyle = false) => (
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">{label}</Label>
|
||||
{isEditModeStyle ? (
|
||||
<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;
|
||||
|
||||
// 상태별 버튼 구성
|
||||
const showInspectionButton = detail.status === 'inspection_pending';
|
||||
const showReceivingProcessButton =
|
||||
detail.status === 'order_completed' || detail.status === 'shipping';
|
||||
const showReceiptButton =
|
||||
detail.status === 'inspection_pending' || detail.status === 'completed';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 발주번호와 상태 뱃지 */}
|
||||
<span className="text-lg text-muted-foreground">{detail.orderNo}</span>
|
||||
<Badge className={`${RECEIVING_STATUS_STYLES[detail.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
{showReceiptButton && (
|
||||
<Button variant="outline" onClick={handleOpenReceipt}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
입고증
|
||||
</Button>
|
||||
)}
|
||||
{showInspectionButton && (
|
||||
<Button onClick={handleGoToInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 mr-1.5" />
|
||||
검사등록
|
||||
</Button>
|
||||
)}
|
||||
{showReceivingProcessButton && (
|
||||
<Button onClick={handleOpenReceivingProcessDialog}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
입고처리
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [detail, handleOpenReceipt, handleGoToInspection, handleOpenReceivingProcessDialog]);
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
// 입고 정보 표시 여부: 검사대기, 입고대기, 입고완료
|
||||
const showReceivingInfo =
|
||||
detail.status === 'inspection_pending' ||
|
||||
detail.status === 'receiving_pending' ||
|
||||
detail.status === 'completed';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 발주 정보 */}
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">발주 정보</CardTitle>
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주번호</p>
|
||||
<p className="font-medium">{detail.orderNo}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{renderReadOnlyField('로트번호', detail.lotNo)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('발주처', detail.supplier)}
|
||||
{renderReadOnlyField('입고수량', detail.receivingQty)}
|
||||
{renderReadOnlyField('입고일', detail.receivingDate)}
|
||||
{renderReadOnlyField('작성자', detail.createdBy)}
|
||||
{renderReadOnlyField('상태',
|
||||
detail.status === 'receiving_pending' ? '입고대기' :
|
||||
detail.status === 'completed' ? '입고완료' :
|
||||
detail.status === 'inspection_completed' ? '검사완료' :
|
||||
detail.status
|
||||
)}
|
||||
{renderReadOnlyField('비고', detail.remark)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수입검사 정보 */}
|
||||
<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-4">
|
||||
{renderReadOnlyField('검사일', detail.inspectionDate)}
|
||||
{renderReadOnlyField('검사결과', detail.inspectionResult)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Label className="text-sm text-muted-foreground">업체 제공 성적서 자료</Label>
|
||||
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 text-center text-sm text-muted-foreground">
|
||||
{detail.certificateFileName ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
<span>{detail.certificateFileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
'등록된 파일이 없습니다.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
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-4">
|
||||
{/* 로트번호 - 읽기전용 */}
|
||||
{renderReadOnlyField('로트번호', formData.lotNo, true)}
|
||||
|
||||
{/* 품목코드 - 수정가능 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주일자</p>
|
||||
<p className="font-medium">{detail.orderDate || '-'}</p>
|
||||
<Label htmlFor="itemCode" className="text-sm text-muted-foreground">
|
||||
품목코드 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="itemCode"
|
||||
value={formData.itemCode || ''}
|
||||
onChange={(e) => handleInputChange('itemCode', e.target.value)}
|
||||
className="mt-1.5"
|
||||
placeholder="품목코드 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목명 - 읽기전용 */}
|
||||
{renderReadOnlyField('품목명', formData.itemName, true)}
|
||||
|
||||
{/* 규격 - 읽기전용 */}
|
||||
{renderReadOnlyField('규격', formData.specification, true)}
|
||||
|
||||
{/* 단위 - 읽기전용 */}
|
||||
{renderReadOnlyField('단위', formData.unit, true)}
|
||||
|
||||
{/* 발주처 - 수정가능 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공급업체</p>
|
||||
<p className="font-medium">{detail.supplier}</p>
|
||||
<Label htmlFor="supplier" className="text-sm text-muted-foreground">
|
||||
발주처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="supplier"
|
||||
value={formData.supplier || ''}
|
||||
onChange={(e) => handleInputChange('supplier', e.target.value)}
|
||||
className="mt-1.5"
|
||||
placeholder="발주처 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입고수량 - 수정가능 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목코드</p>
|
||||
<p className="font-medium">{detail.itemCode}</p>
|
||||
<Label htmlFor="receivingQty" className="text-sm text-muted-foreground">
|
||||
입고수량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="receivingQty"
|
||||
type="number"
|
||||
value={formData.receivingQty ?? ''}
|
||||
onChange={(e) => handleInputChange('receivingQty', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="mt-1.5"
|
||||
placeholder="입고수량 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입고일 - 수정가능 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목명</p>
|
||||
<p className="font-medium">{detail.itemName}</p>
|
||||
<Label htmlFor="receivingDate" className="text-sm text-muted-foreground">
|
||||
입고일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="receivingDate"
|
||||
type="date"
|
||||
value={formData.receivingDate || ''}
|
||||
onChange={(e) => handleInputChange('receivingDate', e.target.value)}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 작성자 - 읽기전용 */}
|
||||
{renderReadOnlyField('작성자', formData.createdBy, true)}
|
||||
|
||||
{/* 상태 - 수정가능 (셀렉트) */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">규격</p>
|
||||
<p className="font-medium">{detail.specification || '-'}</p>
|
||||
<Label htmlFor="status" className="text-sm text-muted-foreground">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
key={`status-${formData.status}`}
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleInputChange('status', value as ReceivingStatus)}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECEIVING_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 - 수정가능 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주수량</p>
|
||||
<p className="font-medium">
|
||||
{detail.orderQty} {detail.orderUnit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-medium">{detail.dueDate || '-'}</p>
|
||||
<Label htmlFor="remark" className="text-sm text-muted-foreground">
|
||||
비고
|
||||
</Label>
|
||||
<Input
|
||||
id="remark"
|
||||
value={formData.remark || ''}
|
||||
onChange={(e) => handleInputChange('remark', e.target.value)}
|
||||
className="mt-1.5"
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 입고 정보 - 검사대기/입고대기/입고완료 상태에서만 표시 */}
|
||||
{showReceivingInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">입고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고일자</p>
|
||||
<p className="font-medium">{detail.receivingDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고수량</p>
|
||||
<p className="font-medium">
|
||||
{detail.receivingQty !== undefined
|
||||
? `${detail.receivingQty} ${detail.orderUnit}`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고LOT</p>
|
||||
<p className="font-medium">{detail.receivingLot || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공급업체LOT</p>
|
||||
<p className="font-medium">{detail.supplierLot || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고위치</p>
|
||||
<p className="font-medium">{detail.receivingLocation || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고담당</p>
|
||||
<p className="font-medium">{detail.receivingManager || '-'}</p>
|
||||
{/* 수입검사 정보 */}
|
||||
<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-4">
|
||||
{/* 검사일 - 읽기전용 */}
|
||||
{renderReadOnlyField('검사일', formData.inspectionDate, true)}
|
||||
|
||||
{/* 검사결과 - 읽기전용 */}
|
||||
{renderReadOnlyField('검사결과', formData.inspectionResult, true)}
|
||||
</div>
|
||||
|
||||
{/* 업체 제공 성적서 자료 - 파일 업로드 */}
|
||||
<div className="mt-4">
|
||||
<Label className="text-sm text-muted-foreground">업체 제공 성적서 자료</Label>
|
||||
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload className="w-8 h-8 text-gray-400" />
|
||||
<span>클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
}, [formData]);
|
||||
|
||||
// 에러 상태 표시
|
||||
if (!isLoading && (error || !detail)) {
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleInspection}>
|
||||
수입검사하기
|
||||
</Button>
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
// 에러 상태 표시 (view/edit 모드에서만)
|
||||
if (!isNewMode && !isLoading && (error || !detail)) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="입고 정보를 불러올 수 없습니다"
|
||||
@@ -272,45 +438,58 @@ export function ReceivingDetail({ id }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 config 생성
|
||||
const dynamicConfig = {
|
||||
...receivingConfig,
|
||||
title: isViewMode ? '입고 상세' : '입고',
|
||||
description: isNewMode
|
||||
? '새로운 입고를 등록합니다'
|
||||
: isEditMode
|
||||
? '입고 정보를 수정합니다'
|
||||
: '입고 상세를 관리합니다',
|
||||
actions: {
|
||||
...receivingConfig.actions,
|
||||
showEdit: isViewMode,
|
||||
showDelete: false,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={receivingConfig}
|
||||
mode="view"
|
||||
config={dynamicConfig}
|
||||
mode={isNewMode ? 'create' : isEditMode ? 'edit' : 'view'}
|
||||
initialData={detail || {}}
|
||||
itemId={id}
|
||||
itemId={isNewMode ? undefined : id}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderView={() => renderViewContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
|
||||
{/* 입고증 다이얼로그 */}
|
||||
{detail && (
|
||||
<ReceivingReceiptDialog
|
||||
open={isReceiptDialogOpen}
|
||||
onOpenChange={setIsReceiptDialogOpen}
|
||||
detail={detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 입고처리 다이얼로그 */}
|
||||
{detail && (
|
||||
<ReceivingProcessDialog
|
||||
open={isReceivingProcessDialogOpen}
|
||||
onOpenChange={setIsReceivingProcessDialogOpen}
|
||||
detail={detail}
|
||||
onComplete={handleReceivingComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 성공 다이얼로그 */}
|
||||
<SuccessDialog
|
||||
open={successDialog.open}
|
||||
type={successDialog.type}
|
||||
lotNo={successDialog.lotNo}
|
||||
onClose={handleSuccessDialogClose}
|
||||
{/* 수입검사 성적서 모달 */}
|
||||
<InspectionModalV2
|
||||
isOpen={isInspectionModalOpen}
|
||||
onClose={() => setIsInspectionModalOpen(false)}
|
||||
document={{
|
||||
id: 'import-inspection',
|
||||
type: 'import',
|
||||
title: '수입검사 성적서',
|
||||
}}
|
||||
documentItem={{
|
||||
id: id,
|
||||
title: detail?.itemName || '수입검사 성적서',
|
||||
date: detail?.inspectionDate || '',
|
||||
code: detail?.lotNo || '',
|
||||
}}
|
||||
// 수입검사 템플릿 로드용 props
|
||||
itemName={detail?.itemName}
|
||||
specification={detail?.specification}
|
||||
supplier={detail?.supplier}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고 목록 - UniversalListPage 마이그레이션
|
||||
* 입고 목록 - 기획서 기준 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 서버 사이드 페이지네이션 (getReceivings API)
|
||||
* - 통계 카드 (getReceivingStats API)
|
||||
* - 고정 탭 필터 (전체/입고대기/입고완료)
|
||||
* - 테이블 푸터 (요약 정보)
|
||||
* 기획서 기준:
|
||||
* - 날짜 범위 필터
|
||||
* - 상태 셀렉트 필터 (전체, 입고대기, 입고완료, 검사완료)
|
||||
* - 통계 카드 (입고대기, 입고완료, 검사 중, 검사완료)
|
||||
* - 입고 등록 버튼
|
||||
* - 테이블 헤더: 체크박스, 번호, 로트번호, 수입검사, 검사일, 발주처, 품목코드, 품목명, 규격, 단위, 입고수량, 입고일, 작성자, 상태
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
Truck,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ClipboardCheck,
|
||||
Calendar,
|
||||
Plus,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -28,9 +30,9 @@ import {
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type TabOption,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getReceivings, getReceivingStats } from './actions';
|
||||
@@ -48,6 +50,17 @@ export function ReceivingList() {
|
||||
const [stats, setStats] = useState<ReceivingStats | null>(null);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// ===== 날짜 범위 상태 =====
|
||||
const today = new Date();
|
||||
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [startDate, setStartDate] = useState<string>(firstDayOfMonth.toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
|
||||
|
||||
// ===== 필터 상태 =====
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
status: 'all',
|
||||
});
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
@@ -72,60 +85,70 @@ export function ReceivingList() {
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 입고 등록 핸들러 =====
|
||||
const handleRegister = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management/new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const statCards: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '입고대기',
|
||||
value: `${stats?.receivingPendingCount ?? 0}건`,
|
||||
icon: Package,
|
||||
value: `${stats?.receivingPendingCount ?? 0}`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '배송중',
|
||||
value: `${stats?.shippingCount ?? 0}건`,
|
||||
icon: Truck,
|
||||
iconColor: 'text-blue-600',
|
||||
label: '입고완료',
|
||||
value: `${stats?.receivingCompletedCount ?? 0}`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '검사대기',
|
||||
value: `${stats?.inspectionPendingCount ?? 0}건`,
|
||||
label: '검사 중',
|
||||
value: `${stats?.inspectionPendingCount ?? 0}`,
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '금일입고',
|
||||
value: `${stats?.todayReceivingCount ?? 0}건`,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-green-600',
|
||||
label: '검사완료',
|
||||
value: `${stats?.inspectionCompletedCount ?? 0}`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
],
|
||||
[stats]
|
||||
);
|
||||
|
||||
// ===== 탭 옵션 (고정) =====
|
||||
const tabs: TabOption[] = useMemo(
|
||||
() => [
|
||||
{ value: 'all', label: '전체', count: totalItems },
|
||||
{ value: 'receiving_pending', label: '입고대기', count: stats?.receivingPendingCount ?? 0 },
|
||||
{ value: 'completed', label: '입고완료', count: stats?.todayReceivingCount ?? 0 },
|
||||
],
|
||||
[totalItems, stats]
|
||||
);
|
||||
// ===== 필터 설정 =====
|
||||
const filterConfig: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'receiving_pending', label: '입고대기' },
|
||||
{ value: 'completed', label: '입고완료' },
|
||||
{ value: 'inspection_completed', label: '검사완료' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 테이블 푸터 =====
|
||||
const tableFooter = useMemo(
|
||||
() => (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={10} className="py-3">
|
||||
<TableCell colSpan={15} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {totalItems}건 / 입고대기 {stats?.receivingPendingCount ?? 0}건 / 검사대기{' '}
|
||||
{stats?.inspectionPendingCount ?? 0}건
|
||||
총 {totalItems}건
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
[totalItems, stats]
|
||||
[totalItems]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
@@ -133,7 +156,7 @@ export function ReceivingList() {
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '입고 목록',
|
||||
description: '입고 관리',
|
||||
description: '입고를 관리합니다',
|
||||
icon: Package,
|
||||
basePath: '/material/receiving-management',
|
||||
|
||||
@@ -144,11 +167,14 @@ export function ReceivingList() {
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const statusFilter = params?.filters?.status as string;
|
||||
const result = await getReceivings({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
status: params?.tab !== 'all' ? params?.tab : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
search: params?.search || undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -158,7 +184,7 @@ export function ReceivingList() {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
|
||||
// totalItems 업데이트 (푸터 및 탭용)
|
||||
// totalItems 업데이트
|
||||
setTotalItems(result.pagination.total);
|
||||
|
||||
return {
|
||||
@@ -176,17 +202,21 @@ export function ReceivingList() {
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
// 테이블 컬럼 (기획서 순서)
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'orderNo', label: '발주번호', className: 'min-w-[150px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[130px]' },
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'w-[120px]' },
|
||||
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[80px] text-center' },
|
||||
{ key: 'inspectionDate', label: '검사일', className: 'w-[100px] text-center' },
|
||||
{ key: 'supplier', label: '발주처', className: 'min-w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'supplier', label: '공급업체', className: 'min-w-[100px]' },
|
||||
{ key: 'orderQty', label: '발주수량', className: 'w-[100px] text-center' },
|
||||
{ key: 'receivingQty', label: '입고수량', className: 'w-[100px] text-center' },
|
||||
{ key: 'lotNo', label: 'LOT번호', className: 'w-[120px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'receivingQty', label: '입고수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'receivingDate', label: '입고일', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdBy', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
@@ -194,15 +224,38 @@ export function ReceivingList() {
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '발주번호, 품목코드, 품목명, 공급업체 검색...',
|
||||
searchPlaceholder: '로트번호, 품목코드, 품목명 검색...',
|
||||
|
||||
// 탭 설정
|
||||
tabs,
|
||||
defaultTab: 'all',
|
||||
// 날짜 범위 필터
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig,
|
||||
initialFilters: filterValues,
|
||||
|
||||
// 통계 카드
|
||||
stats: statCards,
|
||||
|
||||
// 헤더 액션 (입고 등록 버튼)
|
||||
headerActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleRegister}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
입고 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 푸터
|
||||
tableFooter,
|
||||
|
||||
@@ -226,17 +279,19 @@ export function ReceivingList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.orderNo}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.inspectionStatus || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.inspectionDate || '-'}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.orderQty} {item.orderUnit}
|
||||
</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.receivingQty !== undefined ? item.receivingQty : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.lotNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.receivingDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.createdBy || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${RECEIVING_STATUS_STYLES[item.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[item.status]}
|
||||
@@ -265,9 +320,11 @@ export function ReceivingList() {
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.orderNo}
|
||||
</Badge>
|
||||
{item.lotNo && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.lotNo}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
@@ -279,13 +336,11 @@ export function ReceivingList() {
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="공급업체" value={item.supplier} />
|
||||
<InfoField label="발주수량" value={`${item.orderQty} ${item.orderUnit}`} />
|
||||
<InfoField
|
||||
label="입고수량"
|
||||
value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'}
|
||||
/>
|
||||
<InfoField label="LOT번호" value={item.lotNo || '-'} />
|
||||
<InfoField label="발주처" value={item.supplier} />
|
||||
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
|
||||
<InfoField label="검사일" value={item.inspectionDate || '-'} />
|
||||
<InfoField label="입고수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
|
||||
<InfoField label="입고일" value={item.receivingDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
@@ -310,8 +365,13 @@ export function ReceivingList() {
|
||||
);
|
||||
},
|
||||
}),
|
||||
[tabs, statCards, tableFooter, handleRowClick]
|
||||
[statCards, filterConfig, filterValues, tableFooter, handleRowClick, handleRegister, startDate, endDate]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
|
||||
'use server';
|
||||
|
||||
// ===== 목데이터 모드 플래그 =====
|
||||
const USE_MOCK_DATA = true;
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
@@ -24,6 +27,235 @@ import type {
|
||||
ReceivingProcessFormData,
|
||||
} from './types';
|
||||
|
||||
// ===== 목데이터 =====
|
||||
const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
lotNo: 'LOT-2026-001',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-25',
|
||||
supplier: '(주)대한철강',
|
||||
itemCode: 'STEEL-001',
|
||||
itemName: 'SUS304 스테인리스 판재',
|
||||
specification: '1000x2000x3T',
|
||||
unit: 'EA',
|
||||
receivingQty: 100,
|
||||
receivingDate: '2026-01-26',
|
||||
createdBy: '김철수',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
lotNo: 'LOT-2026-002',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-26',
|
||||
supplier: '삼성전자부품',
|
||||
itemCode: 'ELEC-002',
|
||||
itemName: 'MCU 컨트롤러 IC',
|
||||
specification: 'STM32F103C8T6',
|
||||
unit: 'EA',
|
||||
receivingQty: 500,
|
||||
receivingDate: '2026-01-27',
|
||||
createdBy: '이영희',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
lotNo: 'LOT-2026-003',
|
||||
inspectionStatus: '-',
|
||||
inspectionDate: undefined,
|
||||
supplier: '한국플라스틱',
|
||||
itemCode: 'PLAS-003',
|
||||
itemName: 'ABS 사출 케이스',
|
||||
specification: '150x100x50',
|
||||
unit: 'SET',
|
||||
receivingQty: undefined,
|
||||
receivingDate: undefined,
|
||||
createdBy: '박민수',
|
||||
status: 'receiving_pending',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
lotNo: 'LOT-2026-004',
|
||||
inspectionStatus: '부적',
|
||||
inspectionDate: '2026-01-27',
|
||||
supplier: '(주)대한철강',
|
||||
itemCode: 'STEEL-002',
|
||||
itemName: '알루미늄 프로파일',
|
||||
specification: '40x40x2000L',
|
||||
unit: 'EA',
|
||||
receivingQty: 50,
|
||||
receivingDate: '2026-01-28',
|
||||
createdBy: '김철수',
|
||||
status: 'inspection_pending',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
lotNo: 'LOT-2026-005',
|
||||
inspectionStatus: '-',
|
||||
inspectionDate: undefined,
|
||||
supplier: '글로벌전자',
|
||||
itemCode: 'ELEC-005',
|
||||
itemName: 'DC 모터 24V',
|
||||
specification: '24V 100RPM',
|
||||
unit: 'EA',
|
||||
receivingQty: undefined,
|
||||
receivingDate: undefined,
|
||||
createdBy: '최지훈',
|
||||
status: 'receiving_pending',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
lotNo: 'LOT-2026-006',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-24',
|
||||
supplier: '동양화학',
|
||||
itemCode: 'CHEM-001',
|
||||
itemName: '에폭시 접착제',
|
||||
specification: '500ml',
|
||||
unit: 'EA',
|
||||
receivingQty: 200,
|
||||
receivingDate: '2026-01-25',
|
||||
createdBy: '이영희',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
lotNo: 'LOT-2026-007',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-28',
|
||||
supplier: '삼성전자부품',
|
||||
itemCode: 'ELEC-007',
|
||||
itemName: '커패시터 100uF',
|
||||
specification: '100uF 50V',
|
||||
unit: 'EA',
|
||||
receivingQty: 1000,
|
||||
receivingDate: '2026-01-28',
|
||||
createdBy: '박민수',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
lotNo: 'LOT-2026-008',
|
||||
inspectionStatus: '-',
|
||||
inspectionDate: undefined,
|
||||
supplier: '한국볼트',
|
||||
itemCode: 'BOLT-001',
|
||||
itemName: 'SUS 볼트 M8x30',
|
||||
specification: 'M8x30 SUS304',
|
||||
unit: 'EA',
|
||||
receivingQty: undefined,
|
||||
receivingDate: undefined,
|
||||
createdBy: '김철수',
|
||||
status: 'receiving_pending',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_RECEIVING_STATS: ReceivingStats = {
|
||||
receivingPendingCount: 3,
|
||||
receivingCompletedCount: 4,
|
||||
inspectionPendingCount: 1,
|
||||
inspectionCompletedCount: 5,
|
||||
};
|
||||
|
||||
// 기획서 2026-01-28 기준 상세 목데이터
|
||||
const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
// 기본 정보
|
||||
lotNo: 'LOT-2026-001',
|
||||
itemCode: 'STEEL-001',
|
||||
itemName: 'SUS304 스테인리스 판재',
|
||||
specification: '1000x2000x3T',
|
||||
unit: 'EA',
|
||||
supplier: '(주)대한철강',
|
||||
receivingQty: 100,
|
||||
receivingDate: '2026-01-26',
|
||||
createdBy: '김철수',
|
||||
status: 'completed',
|
||||
remark: '',
|
||||
// 수입검사 정보
|
||||
inspectionDate: '2026-01-25',
|
||||
inspectionResult: '합격',
|
||||
certificateFile: undefined,
|
||||
// 하위 호환
|
||||
orderNo: 'PO-2026-001',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
lotNo: 'LOT-2026-002',
|
||||
itemCode: 'ELEC-002',
|
||||
itemName: 'MCU 컨트롤러 IC',
|
||||
specification: 'STM32F103C8T6',
|
||||
unit: 'EA',
|
||||
supplier: '삼성전자부품',
|
||||
receivingQty: 500,
|
||||
receivingDate: '2026-01-27',
|
||||
createdBy: '이영희',
|
||||
status: 'completed',
|
||||
remark: '긴급 입고',
|
||||
inspectionDate: '2026-01-26',
|
||||
inspectionResult: '합격',
|
||||
orderNo: 'PO-2026-002',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
'3': {
|
||||
id: '3',
|
||||
lotNo: 'LOT-2026-003',
|
||||
itemCode: 'PLAS-003',
|
||||
itemName: 'ABS 사출 케이스',
|
||||
specification: '150x100x50',
|
||||
unit: 'SET',
|
||||
supplier: '한국플라스틱',
|
||||
receivingQty: undefined,
|
||||
receivingDate: undefined,
|
||||
createdBy: '박민수',
|
||||
status: 'receiving_pending',
|
||||
remark: '',
|
||||
inspectionDate: undefined,
|
||||
inspectionResult: undefined,
|
||||
orderNo: 'PO-2026-003',
|
||||
orderUnit: 'SET',
|
||||
},
|
||||
'4': {
|
||||
id: '4',
|
||||
lotNo: 'LOT-2026-004',
|
||||
itemCode: 'STEEL-002',
|
||||
itemName: '알루미늄 프로파일',
|
||||
specification: '40x40x2000L',
|
||||
unit: 'EA',
|
||||
supplier: '(주)대한철강',
|
||||
receivingQty: 50,
|
||||
receivingDate: '2026-01-28',
|
||||
createdBy: '김철수',
|
||||
status: 'inspection_pending',
|
||||
remark: '검사 진행 중',
|
||||
inspectionDate: '2026-01-27',
|
||||
inspectionResult: '불합격',
|
||||
orderNo: 'PO-2026-004',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
'5': {
|
||||
id: '5',
|
||||
lotNo: 'LOT-2026-005',
|
||||
itemCode: 'ELEC-005',
|
||||
itemName: 'DC 모터 24V',
|
||||
specification: '24V 100RPM',
|
||||
unit: 'EA',
|
||||
supplier: '글로벌전자',
|
||||
receivingQty: undefined,
|
||||
receivingDate: undefined,
|
||||
createdBy: '최지훈',
|
||||
status: 'receiving_pending',
|
||||
remark: '',
|
||||
inspectionDate: undefined,
|
||||
inspectionResult: undefined,
|
||||
orderNo: 'PO-2026-005',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
};
|
||||
|
||||
// ===== API 데이터 타입 =====
|
||||
interface ReceivingApiData {
|
||||
id: number;
|
||||
@@ -171,6 +403,46 @@ export async function getReceivings(params?: {
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
// ===== 목데이터 모드 =====
|
||||
if (USE_MOCK_DATA) {
|
||||
let filteredData = [...MOCK_RECEIVING_LIST];
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status && params.status !== 'all') {
|
||||
filteredData = filteredData.filter(item => item.status === params.status);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
item =>
|
||||
item.lotNo?.toLowerCase().includes(search) ||
|
||||
item.itemCode.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.supplier.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const page = params?.page || 1;
|
||||
const perPage = params?.perPage || 20;
|
||||
const total = filteredData.length;
|
||||
const lastPage = Math.ceil(total / perPage);
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + perPage);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: paginatedData,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
lastPage,
|
||||
perPage,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -260,6 +532,11 @@ export async function getReceivingStats(): Promise<{
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
// ===== 목데이터 모드 =====
|
||||
if (USE_MOCK_DATA) {
|
||||
return { success: true, data: MOCK_RECEIVING_STATS };
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/stats`,
|
||||
@@ -295,6 +572,15 @@ export async function getReceivingById(id: string): Promise<{
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
// ===== 목데이터 모드 =====
|
||||
if (USE_MOCK_DATA) {
|
||||
const detail = MOCK_RECEIVING_DETAIL[id];
|
||||
if (detail) {
|
||||
return { success: true, data: detail };
|
||||
}
|
||||
return { success: false, error: '입고 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${id}`,
|
||||
@@ -457,4 +743,228 @@ export async function processReceiving(
|
||||
console.error('[ReceivingActions] processReceiving error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 수입검사 템플릿 타입 (ImportInspectionDocument와 동일) =====
|
||||
export interface InspectionTemplateResponse {
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
headerInfo: {
|
||||
productName: string;
|
||||
specification: string;
|
||||
materialNo: string;
|
||||
lotSize: number;
|
||||
supplier: string;
|
||||
lotNo: string;
|
||||
inspectionDate: string;
|
||||
inspector: string;
|
||||
reportDate: string;
|
||||
approvers: {
|
||||
writer?: string;
|
||||
reviewer?: string;
|
||||
approver?: string;
|
||||
};
|
||||
};
|
||||
inspectionItems: Array<{
|
||||
id: string;
|
||||
no: number;
|
||||
name: string;
|
||||
subName?: string;
|
||||
parentId?: string;
|
||||
standard: {
|
||||
description?: string;
|
||||
value?: string | number;
|
||||
options?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
tolerance: string;
|
||||
isSelected: boolean;
|
||||
}>;
|
||||
};
|
||||
inspectionMethod: string;
|
||||
inspectionCycle: string;
|
||||
measurementType: 'okng' | 'numeric' | 'both';
|
||||
measurementCount: number;
|
||||
rowSpan?: number;
|
||||
isSubRow?: boolean;
|
||||
}>;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
// ===== 수입검사 템플릿 조회 (품목명/규격 기반) =====
|
||||
export async function getInspectionTemplate(params: {
|
||||
itemName: string;
|
||||
specification: string;
|
||||
lotNo?: string;
|
||||
supplier?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: InspectionTemplateResponse;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
// ===== 목데이터 모드 - EGI 강판 템플릿 반환 =====
|
||||
if (USE_MOCK_DATA) {
|
||||
// 품목명/규격에 따라 다른 템플릿 반환 (추후 24종 확장)
|
||||
const mockTemplate: InspectionTemplateResponse = {
|
||||
templateId: 'EGI-001',
|
||||
templateName: '전기 아연도금 강판',
|
||||
headerInfo: {
|
||||
productName: params.itemName || '전기 아연도금 강판 (KS D 3528, SECC) "EGI 평국판"',
|
||||
specification: params.specification || '1.55 * 1218 × 480',
|
||||
materialNo: 'PE02RB',
|
||||
lotSize: 200,
|
||||
supplier: params.supplier || '지오TNS (KG스틸)',
|
||||
lotNo: params.lotNo || '250715-02',
|
||||
inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''),
|
||||
inspector: '노원호',
|
||||
reportDate: new Date().toISOString().split('T')[0],
|
||||
approvers: {
|
||||
writer: '노원호',
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
},
|
||||
},
|
||||
inspectionItems: [
|
||||
{
|
||||
id: 'appearance',
|
||||
no: 1,
|
||||
name: '겉모양',
|
||||
standard: { description: '사용상 해로운 결함이 없을 것' },
|
||||
inspectionMethod: '육안검사',
|
||||
inspectionCycle: '',
|
||||
measurementType: 'okng',
|
||||
measurementCount: 3,
|
||||
},
|
||||
{
|
||||
id: 'thickness',
|
||||
no: 2,
|
||||
name: '치수',
|
||||
subName: '두께',
|
||||
standard: {
|
||||
value: 1.55,
|
||||
options: [
|
||||
{ id: 't1', label: '0.8 이상 ~ 1.0 미만', tolerance: '± 0.07', isSelected: false },
|
||||
{ id: 't2', label: '1.0 이상 ~ 1.25 미만', tolerance: '± 0.08', isSelected: false },
|
||||
{ id: 't3', label: '1.25 이상 ~ 1.6 미만', tolerance: '± 0.10', isSelected: true },
|
||||
{ id: 't4', label: '1.6 이상 ~ 2.0 미만', tolerance: '± 0.12', isSelected: false },
|
||||
],
|
||||
},
|
||||
inspectionMethod: 'n = 3\nc = 0',
|
||||
inspectionCycle: '체크검사',
|
||||
measurementType: 'numeric',
|
||||
measurementCount: 3,
|
||||
rowSpan: 3,
|
||||
},
|
||||
{
|
||||
id: 'width',
|
||||
no: 2,
|
||||
name: '치수',
|
||||
subName: '너비',
|
||||
parentId: 'thickness',
|
||||
standard: {
|
||||
value: 1219,
|
||||
options: [{ id: 'w1', label: '1250 미만', tolerance: '+ 7\n- 0', isSelected: true }],
|
||||
},
|
||||
inspectionMethod: '',
|
||||
inspectionCycle: '',
|
||||
measurementType: 'numeric',
|
||||
measurementCount: 3,
|
||||
isSubRow: true,
|
||||
},
|
||||
{
|
||||
id: 'length',
|
||||
no: 2,
|
||||
name: '치수',
|
||||
subName: '길이',
|
||||
parentId: 'thickness',
|
||||
standard: {
|
||||
value: 480,
|
||||
options: [{ id: 'l1', label: '2000 이상 ~ 4000 미만', tolerance: '+ 15\n- 0', isSelected: true }],
|
||||
},
|
||||
inspectionMethod: '',
|
||||
inspectionCycle: '',
|
||||
measurementType: 'numeric',
|
||||
measurementCount: 3,
|
||||
isSubRow: true,
|
||||
},
|
||||
{
|
||||
id: 'tensileStrength',
|
||||
no: 3,
|
||||
name: '인장강도 (N/㎟)',
|
||||
standard: { description: '270 이상' },
|
||||
inspectionMethod: '',
|
||||
inspectionCycle: '',
|
||||
measurementType: 'numeric',
|
||||
measurementCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'elongation',
|
||||
no: 4,
|
||||
name: '연신율 %',
|
||||
standard: {
|
||||
options: [
|
||||
{ id: 'e1', label: '두께 0.6 이상 ~ 1.0 미만', tolerance: '36 이상', isSelected: false },
|
||||
{ id: 'e2', label: '두께 1.0 이상 ~ 1.6 미만', tolerance: '37 이상', isSelected: true },
|
||||
{ id: 'e3', label: '두께 1.6 이상 ~ 2.3 미만', tolerance: '38 이상', isSelected: false },
|
||||
],
|
||||
},
|
||||
inspectionMethod: '공급업체\n밀시트',
|
||||
inspectionCycle: '입고시',
|
||||
measurementType: 'numeric',
|
||||
measurementCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'zincCoating',
|
||||
no: 5,
|
||||
name: '아연의 최소 부착량 (g/㎡)',
|
||||
standard: { description: '편면 17 이상' },
|
||||
inspectionMethod: '',
|
||||
inspectionCycle: '',
|
||||
measurementType: 'numeric',
|
||||
measurementCount: 2,
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'※ 1.55mm의 경우 KS F 4510에 따른 MIN 1.5의 기준에 따름',
|
||||
'※ 두께의 경우 너비 1000 이상 ~ 1250 미만 기준에 따름',
|
||||
],
|
||||
};
|
||||
|
||||
return { success: true, data: mockTemplate };
|
||||
}
|
||||
|
||||
// ===== 실제 API 호출 =====
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('item_name', params.itemName);
|
||||
searchParams.set('specification', params.specification);
|
||||
if (params.lotNo) searchParams.set('lot_no', params.lotNo);
|
||||
if (params.supplier) searchParams.set('supplier', params.supplier);
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspection-templates?${searchParams.toString()}`,
|
||||
{ method: 'GET', cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '검사 템플릿 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success || !result.data) {
|
||||
return { success: false, error: result.message || '검사 템플릿 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] getInspectionTemplate error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
// 입고 상태
|
||||
export type ReceivingStatus =
|
||||
| 'order_completed' // 발주완료
|
||||
| 'shipping' // 배송중
|
||||
| 'inspection_pending' // 검사대기
|
||||
| 'receiving_pending' // 입고대기
|
||||
| 'completed'; // 입고완료
|
||||
| 'order_completed' // 발주완료
|
||||
| 'shipping' // 배송중
|
||||
| 'inspection_pending' // 검사대기
|
||||
| 'receiving_pending' // 입고대기
|
||||
| 'completed' // 입고완료
|
||||
| 'inspection_completed'; // 검사완료
|
||||
|
||||
// 상태 라벨
|
||||
export const RECEIVING_STATUS_LABELS: Record<ReceivingStatus, string> = {
|
||||
@@ -17,6 +18,7 @@ export const RECEIVING_STATUS_LABELS: Record<ReceivingStatus, string> = {
|
||||
inspection_pending: '검사대기',
|
||||
receiving_pending: '입고대기',
|
||||
completed: '입고완료',
|
||||
inspection_completed: '검사완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
@@ -26,40 +28,65 @@ export const RECEIVING_STATUS_STYLES: Record<ReceivingStatus, string> = {
|
||||
inspection_pending: 'bg-orange-100 text-orange-800',
|
||||
receiving_pending: 'bg-yellow-100 text-yellow-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
inspection_completed: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
// 상세 페이지용 상태 옵션 (셀렉트박스)
|
||||
export const RECEIVING_STATUS_OPTIONS = [
|
||||
{ value: 'receiving_pending', label: '입고대기' },
|
||||
{ value: 'completed', label: '입고완료' },
|
||||
{ value: 'inspection_completed', label: '검사완료' },
|
||||
] as const;
|
||||
|
||||
// 입고 목록 아이템
|
||||
export interface ReceivingItem {
|
||||
id: string;
|
||||
orderNo: string; // 발주번호
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
supplier: string; // 공급업체
|
||||
orderQty: number; // 발주수량
|
||||
orderUnit: string; // 발주단위
|
||||
receivingQty?: number; // 입고수량
|
||||
lotNo?: string; // LOT번호
|
||||
status: ReceivingStatus; // 상태
|
||||
lotNo?: string; // 로트번호
|
||||
inspectionStatus?: string; // 수입검사 (적/부적/-)
|
||||
inspectionDate?: string; // 검사일
|
||||
supplier: string; // 발주처
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
specification?: string; // 규격
|
||||
unit: string; // 단위
|
||||
receivingQty?: number; // 입고수량
|
||||
receivingDate?: string; // 입고일
|
||||
createdBy?: string; // 작성자
|
||||
status: ReceivingStatus; // 상태
|
||||
// 기존 필드 (하위 호환)
|
||||
orderNo?: string; // 발주번호
|
||||
orderQty?: number; // 발주수량
|
||||
orderUnit?: string; // 발주단위
|
||||
}
|
||||
|
||||
// 입고 상세 정보
|
||||
// 입고 상세 정보 (기획서 2026-01-28 기준)
|
||||
export interface ReceivingDetail {
|
||||
id: string;
|
||||
orderNo: string; // 발주번호
|
||||
orderDate?: string; // 발주일자
|
||||
supplier: string; // 공급업체
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
specification?: string; // 규격
|
||||
orderQty: number; // 발주수량
|
||||
orderUnit: string; // 발주단위
|
||||
dueDate?: string; // 납기일
|
||||
status: ReceivingStatus;
|
||||
// 입고 정보
|
||||
receivingDate?: string; // 입고일자
|
||||
receivingQty?: number; // 입고수량
|
||||
receivingLot?: string; // 입고LOT
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
// 기본 정보
|
||||
lotNo?: string; // 로트번호 (읽기전용)
|
||||
itemCode: string; // 품목코드 (수정가능)
|
||||
itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동)
|
||||
specification?: string; // 규격 (읽기전용)
|
||||
unit: string; // 단위 (읽기전용)
|
||||
supplier: string; // 발주처 (수정가능)
|
||||
receivingQty?: number; // 입고수량 (수정가능)
|
||||
receivingDate?: string; // 입고일 (수정가능)
|
||||
createdBy?: string; // 작성자 (읽기전용)
|
||||
status: ReceivingStatus; // 상태 (수정가능)
|
||||
remark?: string; // 비고 (수정가능)
|
||||
// 수입검사 정보
|
||||
inspectionDate?: string; // 검사일 (읽기전용)
|
||||
inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격
|
||||
certificateFile?: string; // 업체 제공 성적서 자료 (수정가능)
|
||||
certificateFileName?: string; // 파일명
|
||||
// 기존 필드 (하위 호환)
|
||||
orderNo?: string; // 발주번호
|
||||
orderDate?: string; // 발주일자
|
||||
orderQty?: number; // 발주수량
|
||||
orderUnit?: string; // 발주단위
|
||||
dueDate?: string; // 납기일
|
||||
receivingLot?: string; // 입고LOT
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingLocation?: string; // 입고위치
|
||||
receivingManager?: string; // 입고담당
|
||||
}
|
||||
@@ -103,10 +130,13 @@ export interface ReceivingProcessFormData {
|
||||
|
||||
// 통계 데이터
|
||||
export interface ReceivingStats {
|
||||
receivingPendingCount: number; // 입고대기
|
||||
shippingCount: number; // 배송중
|
||||
inspectionPendingCount: number; // 검사대기
|
||||
todayReceivingCount: number; // 금일입고
|
||||
receivingPendingCount: number; // 입고대기
|
||||
receivingCompletedCount: number; // 입고완료
|
||||
inspectionPendingCount: number; // 검사 중
|
||||
inspectionCompletedCount: number; // 검사완료
|
||||
// 기존 필드 (하위 호환)
|
||||
shippingCount?: number; // 배송중
|
||||
todayReceivingCount?: number; // 금일입고
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
|
||||
243
src/components/material/StockStatus/StockAuditModal.tsx
Normal file
243
src/components/material/StockStatus/StockAuditModal.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고 실사 모달
|
||||
*
|
||||
* 기능:
|
||||
* - 재고 목록 표시 (품목코드, 품목명, 규격, 단위, 실제 재고량)
|
||||
* - 실제 재고량 입력/수정
|
||||
* - 저장 시 일괄 업데이트
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { updateStockAudit } from './actions';
|
||||
import type { StockItem } from './types';
|
||||
|
||||
interface StockAuditModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stocks: StockItem[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface AuditItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
calculatedQty: number;
|
||||
actualQty: number;
|
||||
newActualQty: number;
|
||||
}
|
||||
|
||||
export function StockAuditModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
stocks,
|
||||
onComplete,
|
||||
}: StockAuditModalProps) {
|
||||
const [auditItems, setAuditItems] = useState<AuditItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 모달이 열릴 때 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (open && stocks.length > 0) {
|
||||
setAuditItems(
|
||||
stocks.map((stock) => ({
|
||||
id: stock.id,
|
||||
itemCode: stock.itemCode,
|
||||
itemName: stock.itemName,
|
||||
specification: stock.specification || '',
|
||||
unit: stock.unit,
|
||||
calculatedQty: stock.calculatedQty,
|
||||
actualQty: stock.actualQty,
|
||||
newActualQty: stock.actualQty,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [open, stocks]);
|
||||
|
||||
// 실제 재고량 변경 핸들러
|
||||
const handleQtyChange = useCallback((id: string, value: string) => {
|
||||
const numValue = value === '' ? 0 : parseFloat(value);
|
||||
if (isNaN(numValue)) return;
|
||||
|
||||
setAuditItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, newActualQty: numValue } : item
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSubmit = async () => {
|
||||
// 변경된 항목만 필터링
|
||||
const changedItems = auditItems.filter(
|
||||
(item) => item.actualQty !== item.newActualQty
|
||||
);
|
||||
|
||||
if (changedItems.length === 0) {
|
||||
toast.info('변경된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updates = changedItems.map((item) => ({
|
||||
id: item.id,
|
||||
actualQty: item.newActualQty,
|
||||
}));
|
||||
|
||||
const result = await updateStockAudit(updates);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${changedItems.length}개 항목의 재고가 업데이트되었습니다.`);
|
||||
onOpenChange(false);
|
||||
onComplete?.();
|
||||
} else {
|
||||
toast.error(result.error || '재고 실사 저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[StockAuditModal] handleSubmit error:', error);
|
||||
toast.error('재고 실사 저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[1200px] w-full p-0 gap-0 max-h-[80vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="p-6 pb-4 flex-shrink-0">
|
||||
<DialogTitle className="text-xl font-semibold">재고 실사</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 space-y-6 flex-1 overflow-hidden flex flex-col">
|
||||
{/* 테이블 */}
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="table" rows={6} />
|
||||
) : auditItems.length === 0 ? (
|
||||
<div className="border rounded-lg flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center">품목코드</TableHead>
|
||||
<TableHead className="text-center">품목명</TableHead>
|
||||
<TableHead className="text-center">규격</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-center">계산 재고량</TableHead>
|
||||
<TableHead className="text-center">실제 재고량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
|
||||
재고 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-auto flex-1">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 bg-gray-50 z-10">
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center font-medium w-[15%]">품목코드</TableHead>
|
||||
<TableHead className="text-center font-medium w-[25%]">품목명</TableHead>
|
||||
<TableHead className="text-center font-medium w-[15%]">규격</TableHead>
|
||||
<TableHead className="text-center font-medium w-[8%]">단위</TableHead>
|
||||
<TableHead className="text-center font-medium w-[12%]">계산 재고량</TableHead>
|
||||
<TableHead className="text-center font-medium w-[15%]">실제 재고량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{auditItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell className="text-center max-w-[200px] truncate" title={item.itemName}>
|
||||
{item.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">
|
||||
{item.calculatedQty}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.newActualQty}
|
||||
onChange={(e) => handleQtyChange(item.id, e.target.value)}
|
||||
className="w-24 text-center mx-auto"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-3 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
'저장'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
* - 테이블 푸터 (요약 정보)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { ExcelColumn } from '@/lib/utils/excel-download';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -29,15 +29,15 @@ import {
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type TabOption,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getStocks, getStockStats, getStockStatsByType } from './actions';
|
||||
import { ITEM_TYPE_LABELS, ITEM_TYPE_STYLES, STOCK_STATUS_LABELS } from './types';
|
||||
import { getStocks, getStockStats } from './actions';
|
||||
import { USE_STATUS_LABELS } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { StockAuditModal } from './StockAuditModal';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -45,57 +45,121 @@ const ITEMS_PER_PAGE = 20;
|
||||
export function StockStatusList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 통계 및 품목유형별 통계 (외부 관리) =====
|
||||
// ===== 통계 (외부 관리) =====
|
||||
const [stockStats, setStockStats] = useState<StockStats | null>(null);
|
||||
const [typeStats, setTypeStats] = useState<Record<string, { label: string; count: number; total_qty: number | string }>>({});
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [statsResult, typeStatsResult] = await Promise.all([
|
||||
getStockStats(),
|
||||
getStockStatsByType(),
|
||||
]);
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStockStats(statsResult.data);
|
||||
}
|
||||
if (typeStatsResult.success && typeStatsResult.data) {
|
||||
setTypeStats(typeStatsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[StockStatusList] loadStats error:', error);
|
||||
// ===== 날짜 범위 상태 =====
|
||||
const today = new Date();
|
||||
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [startDate, setStartDate] = useState<string>(firstDayOfMonth.toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
|
||||
|
||||
// ===== 데이터 상태 (수주관리 패턴) =====
|
||||
const [stocks, setStocks] = useState<StockItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// ===== 검색 및 필터 상태 =====
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
useStatus: 'all',
|
||||
});
|
||||
|
||||
// ===== 재고 실사 모달 상태 =====
|
||||
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
|
||||
const [isAuditLoading, setIsAuditLoading] = useState(false);
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [stocksResult, statsResult] = await Promise.all([
|
||||
getStocks({
|
||||
page: 1,
|
||||
perPage: 9999, // 전체 데이터 로드 (클라이언트 사이드 필터링)
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
getStockStats(),
|
||||
]);
|
||||
|
||||
if (stocksResult.success && stocksResult.data) {
|
||||
setStocks(stocksResult.data);
|
||||
setTotalCount(stocksResult.pagination.total);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStockStats(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[StockStatusList] loadData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터 로드 및 날짜 변경 시 재로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
const filteredStocks = stocks.filter((stock) => {
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
stock.itemCode.toLowerCase().includes(searchLower) ||
|
||||
stock.itemName.toLowerCase().includes(searchLower) ||
|
||||
stock.stockNumber.toLowerCase().includes(searchLower);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
const useStatusFilter = filterValues.useStatus as string;
|
||||
if (useStatusFilter && useStatusFilter !== 'all') {
|
||||
if (stock.useStatus !== useStatusFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: StockItem) => {
|
||||
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const handleRowClick = (item: StockItem) => {
|
||||
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
|
||||
};
|
||||
|
||||
// ===== 재고 실사 버튼 핸들러 =====
|
||||
const handleStockAudit = () => {
|
||||
setIsAuditLoading(true);
|
||||
// 약간의 딜레이 후 모달 오픈 (로딩 UI 표시를 위해)
|
||||
setTimeout(() => {
|
||||
setIsAuditModalOpen(true);
|
||||
setIsAuditLoading(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// ===== 재고 실사 완료 핸들러 =====
|
||||
const handleAuditComplete = () => {
|
||||
loadData(); // 데이터 새로고침
|
||||
};
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
const excelColumns: ExcelColumn<StockItem>[] = useMemo(() => [
|
||||
const excelColumns: ExcelColumn<StockItem>[] = [
|
||||
{ header: '재고번호', key: 'stockNumber' },
|
||||
{ header: '품목코드', key: 'itemCode' },
|
||||
{ header: '품목명', key: 'itemName' },
|
||||
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || String(value) },
|
||||
{ header: '규격', key: 'specification' },
|
||||
{ header: '단위', key: 'unit' },
|
||||
{ header: '재고량', key: 'stockQty' },
|
||||
{ header: '계산 재고량', key: 'calculatedQty' },
|
||||
{ header: '실제 재고량', key: 'actualQty' },
|
||||
{ header: '안전재고', key: 'safetyStock' },
|
||||
{ header: 'LOT수', key: 'lotCount' },
|
||||
{ header: 'LOT경과일', key: 'lotDaysElapsed' },
|
||||
{ header: '상태', key: 'status', transform: (value) => value ? STOCK_STATUS_LABELS[value as StockStatusType] : '-' },
|
||||
{ header: '위치', key: 'location' },
|
||||
], []);
|
||||
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
|
||||
];
|
||||
|
||||
// ===== API 응답 매핑 함수 =====
|
||||
const mapStockResponse = useCallback((result: unknown): StockItem[] => {
|
||||
const mapStockResponse = (result: unknown): StockItem[] => {
|
||||
const data = result as { data?: { data?: Record<string, unknown>[] } };
|
||||
const rawItems = data.data?.data ?? [];
|
||||
return rawItems.map((item: Record<string, unknown>) => {
|
||||
@@ -103,311 +167,324 @@ export function StockStatusList() {
|
||||
const hasStock = !!stock;
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
stockNumber: hasStock ? (String(stock?.stock_number ?? stock?.id ?? item.id)) : String(item.id ?? ''),
|
||||
itemCode: (item.code ?? '') as string,
|
||||
itemName: (item.name ?? '') as string,
|
||||
itemType: (item.item_type ?? 'RM') as ItemType,
|
||||
specification: (item.specification ?? item.attributes ?? '') as string,
|
||||
unit: (item.unit ?? 'EA') as string,
|
||||
calculatedQty: hasStock ? (parseFloat(String(stock?.calculated_qty ?? stock?.stock_qty)) || 0) : 0,
|
||||
actualQty: hasStock ? (parseFloat(String(stock?.actual_qty ?? stock?.stock_qty)) || 0) : 0,
|
||||
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
|
||||
lotCount: hasStock ? (Number(stock?.lot_count) || 0) : 0,
|
||||
lotDaysElapsed: hasStock ? (Number(stock?.days_elapsed) || 0) : 0,
|
||||
status: hasStock ? (stock?.status as StockStatusType | null) : null,
|
||||
useStatus: (item.is_active === false || item.status === 'inactive') ? 'inactive' : 'active',
|
||||
location: hasStock ? ((stock?.location as string) || '-') : '-',
|
||||
hasStock,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: `${stockStats?.totalItems || 0}종`,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '정상 재고',
|
||||
value: `${stockStats?.normalCount || 0}종`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '재고 부족',
|
||||
value: `${stockStats?.lowCount || 0}종`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '재고 없음',
|
||||
value: `${stockStats?.outCount || 0}종`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
],
|
||||
[stockStats]
|
||||
);
|
||||
const stats = [
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: `${stockStats?.totalItems || 0}`,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '정상 재고',
|
||||
value: `${stockStats?.normalCount || 0}`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '재고부족',
|
||||
value: `${stockStats?.lowCount || 0}`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 탭 옵션 (기본 탭 + 품목유형별 통계) =====
|
||||
const tabs: TabOption[] = useMemo(() => {
|
||||
// 기본 탭 정의 (Item 모델의 MATERIAL_TYPES: RM, SM, CS)
|
||||
const defaultTabs: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'RM', label: '원자재' },
|
||||
{ value: 'SM', label: '부자재' },
|
||||
{ value: 'CS', label: '소모품' },
|
||||
];
|
||||
// ===== 필터 설정 (전체/사용/미사용) =====
|
||||
const filterConfig: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'useStatus',
|
||||
label: '상태',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'active', label: '사용' },
|
||||
{ value: 'inactive', label: '미사용' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
return defaultTabs.map((tab) => {
|
||||
if (tab.value === 'all') {
|
||||
return { ...tab, count: stockStats?.totalItems || 0 };
|
||||
}
|
||||
const stat = typeStats[tab.value];
|
||||
const count = typeof stat?.count === 'number' ? stat.count : 0;
|
||||
return { ...tab, count };
|
||||
});
|
||||
}, [typeStats, stockStats?.totalItems]);
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'stockNumber', label: '재고번호', className: 'w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'calculatedQty', label: '계산 재고량', className: 'w-[100px] text-center' },
|
||||
{ key: 'actualQty', label: '실제 재고량', className: 'w-[100px] text-center' },
|
||||
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
|
||||
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// ===== 테이블 푸터 =====
|
||||
const tableFooter = useMemo(() => {
|
||||
const lowStockCount = stockStats?.lowCount || 0;
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
const renderTableRow = (
|
||||
item: StockItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<StockItem>
|
||||
) => {
|
||||
return (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={12} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {totalItems}종 / 재고부족 {lowStockCount}종
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.stockNumber}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.calculatedQty}</TableCell>
|
||||
<TableCell className="text-center">{item.actualQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
|
||||
{USE_STATUS_LABELS[item.useStatus]}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [totalItems, stockStats?.lowCount]);
|
||||
};
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<StockItem> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '재고 목록',
|
||||
description: '재고현황 관리',
|
||||
icon: Package,
|
||||
basePath: '/material/stock-status',
|
||||
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// API 액션 (서버 사이드 페이지네이션)
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getStocks({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
itemType: params?.tab !== 'all' ? (params?.tab as ItemType) : undefined,
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 및 품목유형별 통계 다시 로드
|
||||
const [statsResult, typeStatsResult] = await Promise.all([
|
||||
getStockStats(),
|
||||
getStockStatsByType(),
|
||||
]);
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStockStats(statsResult.data);
|
||||
}
|
||||
if (typeStatsResult.success && typeStatsResult.data) {
|
||||
setTypeStats(typeStatsResult.data);
|
||||
}
|
||||
|
||||
// totalItems 업데이트 (푸터용)
|
||||
setTotalItems(result.pagination.total);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[200px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'stockQty', label: '재고량', className: 'w-[80px] text-center' },
|
||||
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
|
||||
{ key: 'lot', label: 'LOT', className: 'w-[100px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[60px] text-center' },
|
||||
{ key: 'location', label: '위치', className: 'w-[60px] text-center' },
|
||||
],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '품목코드, 품목명 검색...',
|
||||
|
||||
// 탭 설정
|
||||
tabs,
|
||||
defaultTab: 'all',
|
||||
|
||||
// 통계 카드
|
||||
stats,
|
||||
|
||||
// 테이블 푸터
|
||||
tableFooter,
|
||||
|
||||
// 엑셀 다운로드 설정
|
||||
excelDownload: {
|
||||
columns: excelColumns,
|
||||
filename: '재고현황',
|
||||
sheetName: '재고',
|
||||
fetchAllUrl: '/api/proxy/stocks',
|
||||
fetchAllParams: ({ activeTab, searchValue }) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (activeTab && activeTab !== 'all') {
|
||||
params.item_type = activeTab;
|
||||
}
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
mapResponse: mapStockResponse,
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: StockItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<StockItem>
|
||||
) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = (
|
||||
item: StockItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<StockItem>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.stockNumber}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${item.useStatus === 'inactive' ? 'text-gray-400' : ''}`}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[item.itemType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.stockQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{item.lotCount}개</span>
|
||||
{item.lotDaysElapsed > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.lotDaysElapsed}일 경과
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.status ? (
|
||||
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
|
||||
{STOCK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.location}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
{USE_STATUS_LABELS[item.useStatus]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="규격" value={item.specification || '-'} />
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="계산 재고량" value={`${item.calculatedQty}`} />
|
||||
<InfoField label="실제 재고량" value={`${item.actualQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: StockItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<StockItem>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.itemCode}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[item.itemType]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="위치" value={item.location} />
|
||||
<InfoField label="재고량" value={`${item.stockQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
<InfoField
|
||||
label="LOT"
|
||||
value={`${item.lotCount}개${item.lotDaysElapsed > 0 ? ` (${item.lotDaysElapsed}일 경과)` : ''}`}
|
||||
/>
|
||||
<InfoField
|
||||
label="상태"
|
||||
value={item.status ? STOCK_STATUS_LABELS[item.status] : '-'}
|
||||
className={item.status === 'low' ? 'text-orange-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
// ===== UniversalListPage Config (수주관리 패턴 - useMemo 없음) =====
|
||||
const config: UniversalListConfig<StockItem> = {
|
||||
title: '재고 목록',
|
||||
description: '재고를 관리합니다',
|
||||
icon: Package,
|
||||
basePath: '/material/stock-status',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
// 클라이언트 사이드 필터링 (수주관리 패턴)
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: filteredStocks,
|
||||
totalCount: filteredStocks.length,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '품목코드, 품목명 검색...',
|
||||
|
||||
// 검색 필터 함수
|
||||
searchFilter: (stock, searchValue) => {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return (
|
||||
stock.itemCode.toLowerCase().includes(searchLower) ||
|
||||
stock.itemName.toLowerCase().includes(searchLower) ||
|
||||
stock.stockNumber.toLowerCase().includes(searchLower)
|
||||
);
|
||||
},
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
const useStatusVal = fv.useStatus as string;
|
||||
if (useStatusVal && useStatusVal !== 'all' && item.useStatus !== useStatusVal) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 날짜 범위 필터
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig,
|
||||
initialFilters: filterValues,
|
||||
|
||||
// 통계
|
||||
computeStats: () => stats,
|
||||
|
||||
// 헤더 액션 버튼
|
||||
headerActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleStockAudit}
|
||||
disabled={isAuditLoading}
|
||||
>
|
||||
{isAuditLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{isAuditLoading ? '로딩 중...' : '재고 실사'}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 푸터
|
||||
tableFooter: (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={12} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {filteredStocks.length}건
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
// 엑셀 다운로드 설정
|
||||
excelDownload: {
|
||||
columns: excelColumns,
|
||||
filename: '재고현황',
|
||||
sheetName: '재고',
|
||||
fetchAllUrl: '/api/proxy/stocks',
|
||||
fetchAllParams: ({ searchValue, filters }) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (filters?.useStatus && filters.useStatus !== 'all') {
|
||||
params.use_status = filters.useStatus as string;
|
||||
}
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
params.start_date = startDate;
|
||||
params.end_date = endDate;
|
||||
return params;
|
||||
},
|
||||
}),
|
||||
[tabs, stats, tableFooter, handleRowClick, excelColumns, mapStockResponse]
|
||||
mapResponse: mapStockResponse,
|
||||
},
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||
<p className="text-muted-foreground">재고 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
|
||||
{/* 재고 실사 모달 */}
|
||||
<StockAuditModal
|
||||
open={isAuditModalOpen}
|
||||
onOpenChange={setIsAuditModalOpen}
|
||||
stocks={stocks}
|
||||
onComplete={handleAuditComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,17 +114,33 @@ function transformApiToListItem(data: ItemApiData): StockItem {
|
||||
const stock = data.stock;
|
||||
const hasStock = !!stock;
|
||||
|
||||
// description 또는 attributes에서 규격 정보 추출
|
||||
let specification = '';
|
||||
if (data.description) {
|
||||
specification = data.description;
|
||||
} else if (data.attributes && typeof data.attributes === 'object') {
|
||||
const attrs = data.attributes as Record<string, unknown>;
|
||||
if (attrs.specification) {
|
||||
specification = String(attrs.specification);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
stockNumber: hasStock ? String((stock as unknown as Record<string, unknown>).stock_number ?? stock.id ?? data.id) : String(data.id),
|
||||
itemCode: data.code,
|
||||
itemName: data.name,
|
||||
itemType: data.item_type,
|
||||
specification,
|
||||
unit: data.unit || 'EA',
|
||||
calculatedQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).calculated_qty ?? stock.stock_qty)) || 0) : 0,
|
||||
actualQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).actual_qty ?? stock.stock_qty)) || 0) : 0,
|
||||
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
|
||||
lotCount: hasStock ? (stock.lot_count || 0) : 0,
|
||||
lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0,
|
||||
status: hasStock ? stock.status : null,
|
||||
useStatus: data.is_active === false ? 'inactive' : 'active',
|
||||
location: hasStock ? (stock.location || '-') : '-',
|
||||
hasStock,
|
||||
};
|
||||
@@ -210,9 +226,12 @@ export async function getStocks(params?: {
|
||||
search?: string;
|
||||
itemType?: string;
|
||||
status?: string;
|
||||
useStatus?: string;
|
||||
location?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: StockItem[];
|
||||
@@ -232,9 +251,14 @@ export async function getStocks(params?: {
|
||||
if (params?.status && params.status !== 'all') {
|
||||
searchParams.set('status', params.status);
|
||||
}
|
||||
if (params?.useStatus && params.useStatus !== 'all') {
|
||||
searchParams.set('is_active', params.useStatus === 'active' ? '1' : '0');
|
||||
}
|
||||
if (params?.location) searchParams.set('location', params.location);
|
||||
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
|
||||
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
|
||||
if (params?.startDate) searchParams.set('start_date', params.startDate);
|
||||
if (params?.endDate) searchParams.set('end_date', params.endDate);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks${queryString ? `?${queryString}` : ''}`;
|
||||
@@ -410,3 +434,99 @@ export async function getStockById(id: string): Promise<{
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 재고 단건 수정 =====
|
||||
export async function updateStock(
|
||||
id: string,
|
||||
data: {
|
||||
actualQty: number;
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actual_qty: data.actualQty,
|
||||
safety_stock: data.safetyStock,
|
||||
is_active: data.useStatus === 'active',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '재고 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '재고 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[StockActions] updateStock error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 재고 실사 (일괄 업데이트) =====
|
||||
export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/audit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
items: updates.map((u) => ({
|
||||
item_id: u.id,
|
||||
actual_qty: u.actualQty,
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '재고 실사 저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '재고 실사 저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[StockActions] updateStockAudit error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,24 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
|
||||
/**
|
||||
* 재고현황 상세 페이지 Config
|
||||
*
|
||||
* 참고: 이 config는 타이틀/버튼 영역만 정의
|
||||
* 폼 내용은 renderView에서 처리
|
||||
*
|
||||
* 특이사항:
|
||||
* - view 모드만 지원 (edit 없음)
|
||||
* - LOT별 상세 재고 테이블
|
||||
* - FIFO 권장 메시지
|
||||
* 기획서 기준:
|
||||
* - 재고번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용)
|
||||
* - 실제 재고량, 안전재고, 상태 (수정 가능)
|
||||
*/
|
||||
export const stockStatusConfig: DetailConfig = {
|
||||
title: '재고 상세',
|
||||
description: '재고 정보를 조회합니다',
|
||||
description: '재고 상세를 관리합니다',
|
||||
icon: Package,
|
||||
basePath: '/material/stock-status',
|
||||
fields: [], // renderView 사용으로 필드 정의 불필요
|
||||
gridColumns: 3,
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
gridColumns: 4,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: false,
|
||||
showEdit: false, // 수정 기능 없음
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
saveLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,19 +43,30 @@ export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
|
||||
// 재고 목록 아이템 (Item 기준 + Stock 정보)
|
||||
export interface StockItem {
|
||||
id: string;
|
||||
stockNumber: string; // 재고번호 (Stock.stock_number)
|
||||
itemCode: string; // Item.code
|
||||
itemName: string; // Item.name
|
||||
itemType: ItemType; // Item.item_type (RM, SM, CS)
|
||||
specification: string; // 규격 (Item.specification 또는 attributes)
|
||||
unit: string; // Item.unit
|
||||
calculatedQty: number; // 계산 재고량 (Stock.calculated_qty)
|
||||
actualQty: number; // 실제 재고량 (Stock.actual_qty)
|
||||
stockQty: number; // Stock.stock_qty (없으면 0)
|
||||
safetyStock: number; // Stock.safety_stock (없으면 0)
|
||||
lotCount: number; // Stock.lot_count (없으면 0)
|
||||
lotDaysElapsed: number; // Stock.days_elapsed (없으면 0)
|
||||
status: StockStatusType | null; // Stock.status (없으면 null)
|
||||
useStatus: 'active' | 'inactive'; // 사용/미사용 상태
|
||||
location: string; // Stock.location (없으면 '-')
|
||||
hasStock: boolean; // Stock 데이터 존재 여부
|
||||
}
|
||||
|
||||
// 사용 상태 라벨
|
||||
export const USE_STATUS_LABELS: Record<'active' | 'inactive', string> = {
|
||||
active: '사용',
|
||||
inactive: '미사용',
|
||||
};
|
||||
|
||||
// LOT별 상세 재고
|
||||
export interface LotDetail {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user