Files
sam-react-prod/src/components/material/ReceivingManagement/ReceivingDetail.tsx
유병철 3fc63d0b3e feat(WEB): 공정관리/작업지시/작업자화면 기능 강화 및 템플릿 개선
- 공정관리: ProcessDetail/ProcessForm/ProcessList 개선, StepDetail/StepForm 신규 추가
- 작업지시: WorkOrderDetail/Edit/List UI 개선, 작업지시서 문서 추가
- 작업자화면: WorkerScreen 대폭 개선, MaterialInputModal/WorkLogModal 수정, WorkItemCard 신규
- 영업주문: 주문 상세 페이지 개선
- 입고관리: 상세/actions 수정
- 템플릿: IntegratedDetailTemplate/IntegratedListTemplateV2/UniversalListPage 기능 확장
- UI: confirm-dialog 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:56:01 +09:00

626 lines
22 KiB
TypeScript

'use client';
/**
* 입고 상세/등록/수정 페이지
* 기획서 2026-01-28 기준 마이그레이션
*
* mode 패턴:
* - view: 상세 조회 (읽기 전용)
* - edit: 수정 모드
* - new: 신규 등록 모드
*
* 섹션:
* 1. 기본 정보 - 로트번호, 품목코드, 품목명, 규격, 단위, 발주처, 입고수량, 입고일, 작성자, 상태, 비고
* 2. 수입검사 정보 - 검사일, 검사결과, 업체 제공 성적서 자료
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Upload, FileText, Search, X } from 'lucide-react';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
// import { SupplierSearchModal } from './SupplierSearchModal';
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,
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';
import { useDevFill, generateReceivingData } from '@/components/dev';
interface Props {
id: string;
mode?: 'view' | 'edit' | 'new';
}
// 초기 폼 데이터
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
lotNo: '',
itemCode: '',
itemName: '',
specification: '',
unit: 'EA',
supplier: '',
manufacturer: '',
receivingQty: undefined,
receivingDate: '',
createdBy: '',
status: 'receiving_pending',
remark: '',
inspectionDate: '',
inspectionResult: '',
certificateFile: undefined,
};
// 로트번호 생성 (YYMMDD-NN)
function generateLotNo(): string {
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const seq = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `${yy}${mm}${dd}-${seq}`;
}
// localStorage에서 로그인 사용자명 가져오기
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
try {
const userData = localStorage.getItem('user');
if (userData) {
const parsed = JSON.parse(userData);
return parsed.name || '';
}
} catch {
// ignore
}
return '';
}
export function ReceivingDetail({ id, mode = 'view' }: Props) {
const router = useRouter();
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(!isNewMode);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 폼 데이터 (등록/수정 모드용)
const [formData, setFormData] = useState<Partial<ReceivingDetailType>>(INITIAL_FORM_DATA);
// 업로드된 파일 상태 (File 객체)
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
// 수입검사 성적서 모달 상태
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
// Dev 모드 폼 자동 채우기
useDevFill(
'receiving',
useCallback(async () => {
if (!isNewMode) return;
const data = generateReceivingData();
setFormData((prev) => ({
...prev,
lotNo: generateLotNo(),
itemCode: data.itemCode,
itemName: data.itemName,
specification: data.specification,
unit: data.unit,
supplier: data.supplier,
receivingQty: data.receivingQty,
receivingDate: data.receivingDate,
createdBy: getLoggedInUserName(),
status: data.status as ReceivingStatus,
remark: data.remark,
}));
}, [isNewMode])
);
// API 데이터 로드
const loadData = useCallback(async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const result = await getReceivingById(id);
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,
manufacturer: result.data.manufacturer || '',
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 || '입고 정보를 찾을 수 없습니다.');
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ReceivingDetail] loadData error:', err);
setError('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [id, isNewMode, isEditMode]);
// 데이터 로드
useEffect(() => {
loadData();
}, [loadData]);
// 폼 입력 핸들러
const handleInputChange = (field: keyof ReceivingDetailType, value: string | number | undefined) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// 저장 핸들러
const handleSave = async () => {
setIsSaving(true);
try {
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] handleSave error:', err);
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 수입검사하기 버튼 핸들러 - 모달로 표시
const handleInspection = () => {
setIsInspectionModalOpen(true);
};
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
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;
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-4 gap-4">
{renderReadOnlyField('로트번호', detail.lotNo)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목명', detail.itemName)}
{renderReadOnlyField('규격', detail.specification)}
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('발주처', detail.supplier)}
{renderReadOnlyField('제조사', detail.manufacturer)}
{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-4 gap-4">
{/* 로트번호 - 읽기전용 */}
{renderReadOnlyField('로트번호', formData.lotNo, true)}
{/* 품목코드 - 검색 모달 선택 */}
<div>
<Label className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<div className="mt-1.5 flex gap-1">
<Input
value={formData.itemCode || ''}
readOnly
placeholder="품목코드를 검색하세요"
className="cursor-pointer bg-gray-50"
onClick={() => setIsItemSearchOpen(true)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setIsItemSearchOpen(true)}
className="shrink-0"
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* 품목명 - 읽기전용 */}
{renderReadOnlyField('품목명', formData.itemName, true)}
{/* 규격 - 읽기전용 */}
{renderReadOnlyField('규격', formData.specification, true)}
{/* 단위 - 읽기전용 */}
{renderReadOnlyField('단위', formData.unit, true)}
{/* 발주처 - 검색 모달 선택 */}
<div>
<Label className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<div className="mt-1.5 flex gap-1">
<Input
value={formData.supplier || ''}
readOnly
placeholder="발주처를 검색하세요"
className="cursor-pointer bg-gray-50"
onClick={() => setIsSupplierSearchOpen(true)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setIsSupplierSearchOpen(true)}
className="shrink-0"
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* 제조사 - 수정가능 */}
<div>
<Label htmlFor="manufacturer" className="text-sm text-muted-foreground">
</Label>
<Input
id="manufacturer"
value={formData.manufacturer || ''}
onChange={(e) => handleInputChange('manufacturer', e.target.value)}
className="mt-1.5"
placeholder="제조사 입력"
/>
</div>
{/* 입고수량 - 수정가능 */}
<div>
<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>
<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>
<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>
<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>
{/* 수입검사 정보 */}
<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>
</div>
</CardContent>
</Card>
</div>
);
}, [formData]);
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
<>
<Button variant="outline" onClick={handleInspection}>
</Button>
</>
) : undefined;
// 에러 상태 표시 (view/edit 모드에서만)
if (!isNewMode && !isLoading && (error || !detail)) {
return (
<ServerErrorPage
title="입고 정보를 불러올 수 없습니다"
message={error || '입고 정보를 찾을 수 없습니다.'}
onRetry={loadData}
showBackButton={true}
showHomeButton={true}
/>
);
}
// 동적 config 생성
const dynamicConfig = {
...receivingConfig,
title: isViewMode ? '입고 상세' : '입고',
description: isNewMode
? '새로운 입고를 등록합니다'
: isEditMode
? '입고 정보를 수정합니다'
: '입고 상세를 관리합니다',
actions: {
...receivingConfig.actions,
showEdit: isViewMode,
showDelete: false,
},
};
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={isNewMode ? 'create' : isEditMode ? 'edit' : 'view'}
initialData={(detail as unknown as Record<string, unknown>) || {}}
itemId={isNewMode ? undefined : id}
isLoading={isLoading}
headerActions={customHeaderActions}
renderView={() => renderViewContent()}
renderForm={() => renderFormContent()}
onSubmit={async () => {
await handleSave();
return { success: true };
}}
onCancel={handleCancel}
/>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={isItemSearchOpen}
onOpenChange={setIsItemSearchOpen}
onSelectItem={(item) => {
setFormData((prev) => ({
...prev,
itemCode: item.code,
itemName: item.name,
specification: item.specification || '',
}));
}}
/>
{/* 발주처 검색 모달 - TODO: SupplierSearchModal 컴포넌트 생성 필요
<SupplierSearchModal
open={isSupplierSearchOpen}
onOpenChange={setIsSupplierSearchOpen}
onSelectSupplier={(supplier) => {
setFormData((prev) => ({
...prev,
supplier: supplier.name,
}));
}}
/>
*/}
{/* 수입검사 성적서 모달 */}
<InspectionModalV2
isOpen={isInspectionModalOpen}
onClose={() => setIsInspectionModalOpen(false)}
document={{
id: 'import-inspection',
type: 'import',
title: '수입검사 성적서',
count: 0,
}}
documentItem={{
id: id,
title: detail?.itemName || '수입검사 성적서',
date: detail?.inspectionDate || '',
code: detail?.lotNo || '',
}}
// 수입검사 템플릿 로드용 props
itemName={detail?.itemName}
specification={detail?.specification}
supplier={detail?.supplier}
/>
</>
);
}