- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration) - store → stores 디렉토리 이동 및 favoritesStore 추가 - dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리 - Sidebar 리팩토링 및 HeaderFavoritesBar 추가 - DashboardSwitcher 컴포넌트 추가 - 백업 파일(.v1-backup) 및 불필요 코드 정리 - InspectionPreviewModal 레이아웃 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
921 lines
35 KiB
TypeScript
921 lines
35 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, Plus, ClipboardCheck } from 'lucide-react';
|
|
import { FileDropzone } from '@/components/ui/file-dropzone';
|
|
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
|
|
import { InspectionModal } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModal';
|
|
import { ImportInspectionInputModal } from './ImportInspectionInputModal';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
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,
|
|
checkInspectionTemplate,
|
|
} from './actions';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
RECEIVING_STATUS_OPTIONS,
|
|
type ReceivingDetail as ReceivingDetailType,
|
|
type ReceivingStatus,
|
|
type InventoryAdjustmentRecord,
|
|
} 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> = {
|
|
materialNo: '',
|
|
supplierMaterialNo: '',
|
|
lotNo: '',
|
|
itemCode: '',
|
|
itemName: '',
|
|
specification: '',
|
|
unit: 'EA',
|
|
supplier: '',
|
|
manufacturer: '',
|
|
receivingQty: undefined,
|
|
receivingDate: '',
|
|
createdBy: '',
|
|
status: 'receiving_pending',
|
|
remark: '',
|
|
inspectionDate: '',
|
|
inspectionResult: '',
|
|
certificateFile: undefined,
|
|
inventoryAdjustments: [],
|
|
};
|
|
|
|
// 로트번호 생성 (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 getLoggedInUser(): { name: string; department: string } {
|
|
if (typeof window === 'undefined') return { name: '', department: '' };
|
|
try {
|
|
const userData = localStorage.getItem('user');
|
|
if (userData) {
|
|
const parsed = JSON.parse(userData);
|
|
return { name: parsed.name || '', department: parsed.department || '' };
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return { name: '', department: '' };
|
|
}
|
|
|
|
function getLoggedInUserName(): string {
|
|
return getLoggedInUser().name;
|
|
}
|
|
|
|
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 [isImportInspectionModalOpen, setIsImportInspectionModalOpen] = useState(false);
|
|
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
|
|
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
|
|
|
|
// 수입검사 성적서 템플릿 존재 여부
|
|
const [hasInspectionTemplate, setHasInspectionTemplate] = useState(false);
|
|
|
|
// 수입검사 첨부파일 (document_attachments)
|
|
const [inspectionAttachments, setInspectionAttachments] = useState<Array<{
|
|
id: number;
|
|
file_id: number;
|
|
attachment_type: string;
|
|
file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string };
|
|
}>>([]);
|
|
|
|
// 재고 조정 이력 상태
|
|
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
|
|
|
|
// 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 (result.data.inventoryAdjustments) {
|
|
setAdjustments(result.data.inventoryAdjustments);
|
|
}
|
|
// 수정 모드일 때 폼 데이터 설정
|
|
if (isEditMode) {
|
|
setFormData({
|
|
materialNo: result.data.materialNo || '',
|
|
supplierMaterialNo: result.data.supplierMaterialNo || '',
|
|
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,
|
|
});
|
|
}
|
|
|
|
// 수입검사 성적서 템플릿 존재 여부 + 첨부파일 확인
|
|
if (result.data.itemId) {
|
|
const templateCheck = await checkInspectionTemplate(result.data.itemId);
|
|
setHasInspectionTemplate(templateCheck.hasTemplate);
|
|
if (templateCheck.attachments && templateCheck.attachments.length > 0) {
|
|
setInspectionAttachments(templateCheck.attachments);
|
|
}
|
|
} else {
|
|
setHasInspectionTemplate(false);
|
|
}
|
|
} 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 (): Promise<{ success: boolean; error?: string }> => {
|
|
setIsSaving(true);
|
|
try {
|
|
if (isNewMode) {
|
|
const result = await createReceiving(formData);
|
|
if (result.success) {
|
|
toast.success('입고가 등록되었습니다.');
|
|
router.push('/ko/material/receiving-management');
|
|
return { success: true };
|
|
} else {
|
|
toast.error(result.error || '등록에 실패했습니다.');
|
|
return { success: false, 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`);
|
|
return { success: true };
|
|
} else {
|
|
toast.error(result.error || '수정에 실패했습니다.');
|
|
return { success: false, error: result.error };
|
|
}
|
|
}
|
|
return { success: false, error: '알 수 없는 모드입니다.' };
|
|
} catch (err) {
|
|
if (isNextRedirectError(err)) throw err;
|
|
console.error('[ReceivingDetail] handleSave error:', err);
|
|
toast.error('저장 중 오류가 발생했습니다.');
|
|
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// 수입검사하기 버튼 핸들러 - 수입검사 입력 모달 표시
|
|
const handleInspection = () => {
|
|
setIsImportInspectionModalOpen(true);
|
|
};
|
|
|
|
// 수입검사성적서 보기 버튼 핸들러 - 성적서 모달 표시
|
|
const handleViewInspectionReport = () => {
|
|
setIsInspectionModalOpen(true);
|
|
};
|
|
|
|
// 수입검사 저장 완료 핸들러 → 데이터 새로고침
|
|
const handleImportInspectionSave = () => {
|
|
loadData();
|
|
};
|
|
|
|
// 재고 조정 행 추가
|
|
const handleAddAdjustment = () => {
|
|
const newRecord: InventoryAdjustmentRecord = {
|
|
id: `adj-${Date.now()}`,
|
|
adjustmentDate: new Date().toISOString().split('T')[0],
|
|
quantity: 0,
|
|
inspector: getLoggedInUserName() || '홍길동',
|
|
};
|
|
setAdjustments((prev) => [...prev, newRecord]);
|
|
};
|
|
|
|
// 재고 조정 행 삭제
|
|
const handleRemoveAdjustment = (adjId: string) => {
|
|
setAdjustments((prev) => prev.filter((a) => a.id !== adjId));
|
|
};
|
|
|
|
// 재고 조정 수량 변경
|
|
const handleAdjustmentQtyChange = (adjId: string, value: string) => {
|
|
const numValue = value === '' || value === '-' ? 0 : Number(value);
|
|
setAdjustments((prev) =>
|
|
prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a))
|
|
);
|
|
};
|
|
|
|
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
|
|
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.materialNo)}
|
|
{renderReadOnlyField('자재번호', detail.supplierMaterialNo)}
|
|
{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
|
|
)}
|
|
</div>
|
|
{/* 비고 - 전체 너비 */}
|
|
<div className="mt-4">
|
|
{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>
|
|
{inspectionAttachments.length > 0 ? (
|
|
<div className="mt-1.5 space-y-2">
|
|
{inspectionAttachments.map((att) => {
|
|
const fileName = att.file?.display_name || att.file?.original_name || `file-${att.file_id}`;
|
|
const fileSize = att.file?.file_size;
|
|
const isImage = att.file?.mime_type?.startsWith('image/');
|
|
const downloadUrl = `/api/proxy/files/${att.file_id}/download`;
|
|
return (
|
|
<div key={att.id} className="flex items-center gap-3 p-2 rounded-md border bg-muted/30">
|
|
{isImage && att.file?.file_path ? (
|
|
<img
|
|
src={downloadUrl}
|
|
alt={fileName}
|
|
className="w-10 h-10 rounded object-cover shrink-0"
|
|
/>
|
|
) : (
|
|
<FileText className="w-5 h-5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium truncate">{fileName}</p>
|
|
{fileSize && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{fileSize < 1024 * 1024
|
|
? `${(fileSize / 1024).toFixed(1)} KB`
|
|
: `${(fileSize / (1024 * 1024)).toFixed(1)} MB`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<a
|
|
href={downloadUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
|
>
|
|
다운로드
|
|
</a>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<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">
|
|
첨부된 파일이 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 재고 조정 */}
|
|
<Card>
|
|
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="border rounded-md overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-gray-50">
|
|
<TableHead className="text-center w-[50px]">No</TableHead>
|
|
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
|
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
|
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{adjustments.length > 0 ? (
|
|
adjustments.map((adj, idx) => (
|
|
<TableRow key={adj.id}>
|
|
<TableCell className="text-center">{idx + 1}</TableCell>
|
|
<TableCell className="text-center">{adj.adjustmentDate}</TableCell>
|
|
<TableCell className="text-center">{adj.quantity}</TableCell>
|
|
<TableCell className="text-center">{adj.inspector}</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="text-center py-6 text-muted-foreground">
|
|
재고 조정 이력이 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}, [detail, adjustments, inspectionAttachments]);
|
|
|
|
// ===== 등록/수정 폼 콘텐츠 =====
|
|
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.materialNo, true)}
|
|
|
|
{/* 자재번호 (거래처) - 수정 가능 */}
|
|
<div>
|
|
<Label className="text-sm text-muted-foreground">자재번호</Label>
|
|
<Input
|
|
className="mt-1.5"
|
|
value={formData.supplierMaterialNo || ''}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, supplierMaterialNo: e.target.value }))}
|
|
placeholder="거래처 자재번호"
|
|
/>
|
|
</div>
|
|
|
|
{/* 원자재로트 - 수정 가능 */}
|
|
<div>
|
|
<Label className="text-sm text-muted-foreground">원자재로트</Label>
|
|
<Input
|
|
className="mt-1.5"
|
|
value={formData.lotNo || ''}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, lotNo: e.target.value }))}
|
|
placeholder="원자재로트를 입력하세요"
|
|
/>
|
|
</div>
|
|
|
|
{/* 품목코드 - 검색 모달 선택 */}
|
|
<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>
|
|
<DatePicker
|
|
value={formData.receivingDate || ''}
|
|
onChange={(date) => handleInputChange('receivingDate', date)}
|
|
/>
|
|
</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>
|
|
|
|
{/* 재고 조정 */}
|
|
<Card>
|
|
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAddAdjustment}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
추가
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="border rounded-md overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-gray-50">
|
|
<TableHead className="text-center w-[50px]">No</TableHead>
|
|
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
|
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
|
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
|
<TableHead className="text-center w-[60px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{adjustments.length > 0 ? (
|
|
adjustments.map((adj, idx) => (
|
|
<TableRow key={adj.id}>
|
|
<TableCell className="text-center">{idx + 1}</TableCell>
|
|
<TableCell className="text-center">
|
|
<DatePicker
|
|
value={adj.adjustmentDate}
|
|
onChange={(date) => {
|
|
setAdjustments((prev) =>
|
|
prev.map((a) =>
|
|
a.id === adj.id ? { ...a, adjustmentDate: date } : a
|
|
)
|
|
);
|
|
}}
|
|
size="sm"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Input
|
|
type="number"
|
|
value={adj.quantity || ''}
|
|
onChange={(e) => handleAdjustmentQtyChange(adj.id, e.target.value)}
|
|
className="h-8 text-sm text-center w-[100px] mx-auto"
|
|
placeholder="0"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-center">{adj.inspector}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
|
|
onClick={() => handleRemoveAdjustment(adj.id)}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">
|
|
재고 조정 이력이 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}, [formData, adjustments]);
|
|
|
|
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
|
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
|
|
// 수입검사하기 버튼은 수입검사 성적서 템플릿이 있는 품목만 표시
|
|
const customHeaderActions = (isViewMode || isEditMode) && detail && hasInspectionTemplate ? (
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleInspection}>
|
|
<ClipboardCheck className="w-4 h-4 md:mr-2" />
|
|
<span className="hidden md:inline">수입검사하기</span>
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
|
|
<FileText className="w-4 h-4 md:mr-2" />
|
|
<span className="hidden md:inline">수입검사성적서 보기</span>
|
|
</Button>
|
|
</div>
|
|
) : 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 () => {
|
|
return await handleSave();
|
|
}}
|
|
onCancel={handleCancel}
|
|
/>
|
|
|
|
{/* 품목 검색 모달 */}
|
|
<ItemSearchModal
|
|
open={isItemSearchOpen}
|
|
onOpenChange={setIsItemSearchOpen}
|
|
onSelectItem={(item) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
itemCode: item.code,
|
|
itemName: item.name,
|
|
specification: item.specification || '',
|
|
}));
|
|
}}
|
|
/>
|
|
|
|
<SupplierSearchModal
|
|
open={isSupplierSearchOpen}
|
|
onOpenChange={setIsSupplierSearchOpen}
|
|
onSelectSupplier={(supplier) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
supplier: supplier.name,
|
|
}));
|
|
}}
|
|
/>
|
|
|
|
{/* 수입검사 성적서 모달 (읽기 전용) */}
|
|
<InspectionModal
|
|
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
|
|
itemId={detail?.itemId}
|
|
itemName={detail?.itemName}
|
|
specification={detail?.specification}
|
|
supplier={detail?.supplier}
|
|
inspector={getLoggedInUserName()}
|
|
inspectorDept={getLoggedInUser().department}
|
|
lotSize={detail?.receivingQty}
|
|
materialNo={detail?.materialNo}
|
|
readOnly={true}
|
|
/>
|
|
|
|
{/* 수입검사 입력 모달 */}
|
|
<ImportInspectionInputModal
|
|
open={isImportInspectionModalOpen}
|
|
onOpenChange={setIsImportInspectionModalOpen}
|
|
itemId={detail?.itemId}
|
|
itemName={detail?.itemName}
|
|
specification={detail?.specification}
|
|
supplier={detail?.supplier}
|
|
inspector={getLoggedInUserName()}
|
|
lotSize={detail?.receivingQty}
|
|
materialNo={detail?.materialNo}
|
|
receivingId={id}
|
|
onSave={handleImportInspectionSave}
|
|
/>
|
|
</>
|
|
);
|
|
}
|