Files
sam-react-prod/src/components/material/ReceivingManagement/InspectionCreate.tsx
유병철 00a6209347 feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선
- Sidebar/AuthenticatedLayout 소폭 수정
- ShipmentCreate, VehicleDispatch 출하 관련 개선
- WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선
- InspectionCreate 자재 입고검사 개선
- DailyReport, VendorDetail 회계 수정
- CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선
- useCEODashboard, expense transformer 정비
- DocumentViewer, PDF generate route 소폭 수정
- bill-prototype 개발 페이지 추가
- mockData 불필요 데이터 제거
2026-03-05 13:35:48 +09:00

369 lines
14 KiB
TypeScript

'use client';
/**
* 수입검사 등록 (IQC) 페이지
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
*
* - 검사 대상 선택
* - 검사 정보 입력 (검사일, 검사자*, LOT번호)
* - 검사 항목 테이블 (겉모양, 두께, 폭, 길이)
* - 종합 의견
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { getTodayString } from '@/lib/utils/date';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { materialInspectionCreateConfig } from './inspectionConfig';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getReceivings } from './actions';
import type { InspectionCheckItem, ReceivingItem } from './types';
import { SuccessDialog } from './SuccessDialog';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// LOT 번호 생성 함수 (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 random = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `${yy}${mm}${dd}-${random}`;
}
// 기본 검사 항목
const defaultInspectionItems: InspectionCheckItem[] = [
{ id: '1', name: '겉모양', specification: '외관 이상 없음', method: '육안', judgment: '', remark: '' },
{ id: '2', name: '두께', specification: 't 1.0', method: '계측', judgment: '', remark: '' },
{ id: '3', name: '폭', specification: 'W 1,000mm', method: '계측', judgment: '', remark: '' },
{ id: '4', name: '길이', specification: 'L 2,000mm', method: '계측', judgment: '', remark: '' },
];
interface Props {
id?: string; // 특정 발주건으로 바로 진입하는 경우
}
export function InspectionCreate({ id }: Props) {
const router = useRouter();
// 검사 대상 목록 (API에서 조회)
const [inspectionTargets, setInspectionTargets] = useState<ReceivingItem[]>([]);
const [isLoadingTargets, setIsLoadingTargets] = useState(true);
// 선택된 검사 대상
const [selectedTargetId, setSelectedTargetId] = useState<string>(id || '');
// 검사 정보
const [inspectionDate, setInspectionDate] = useState(() => getTodayString());
const [inspector, setInspector] = useState('');
const [lotNo, setLotNo] = useState(() => generateLotNo());
// 검사 항목
const [inspectionItems, setInspectionItems] = useState<InspectionCheckItem[]>(
defaultInspectionItems.map((item) => ({ ...item }))
);
// 종합 의견
const [opinion, setOpinion] = useState('');
// 유효성 검사 에러
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// 성공 다이얼로그
const [showSuccess, setShowSuccess] = useState(false);
// 검사 대상 목록 로드 (inspection_pending 상태인 입고 건)
useEffect(() => {
const loadTargets = async () => {
setIsLoadingTargets(true);
try {
const result = await getReceivings({ status: 'inspection_pending', perPage: 100 });
if (result.success) {
setInspectionTargets(result.data);
// 초기 선택: id가 있으면 해당 건, 없으면 첫 번째 항목
if (!selectedTargetId && result.data.length > 0) {
setSelectedTargetId(result.data[0].id);
}
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[InspectionCreate] loadTargets error:', err);
} finally {
setIsLoadingTargets(false);
}
};
loadTargets();
}, []);
// 선택된 대상 정보 (나중에 검사 저장 시 사용)
const _selectedTarget = useMemo(() => {
return inspectionTargets.find((t) => t.id === selectedTargetId);
}, [inspectionTargets, selectedTargetId]);
// 대상 선택 핸들러
const handleTargetSelect = useCallback((targetId: string) => {
setSelectedTargetId(targetId);
}, []);
// 판정 변경 핸들러
const handleJudgmentChange = useCallback((itemId: string, index: number, judgment: '적' | '부적') => {
setInspectionItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
);
// 해당 항목의 에러 클리어
setValidationErrors((prev) => {
const key = `judgment_${index}`;
if (prev[key]) {
const { [key]: _, ...rest } = prev;
return rest;
}
return prev;
});
}, []);
// 비고 변경 핸들러
const handleRemarkChange = useCallback((itemId: string, remark: string) => {
setInspectionItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, remark } : item))
);
}, []);
// 유효성 검사
const validateForm = useCallback((): boolean => {
const errors: Record<string, string> = {};
// 필수 필드: 검사자
if (!inspector.trim()) {
errors.inspector = '검사자는 필수 입력 항목입니다.';
}
// 검사 항목 판정 확인
inspectionItems.forEach((item, index) => {
if (!item.judgment) {
errors[`judgment_${index}`] = `${item.name}: 판정을 선택해주세요.`;
}
});
setValidationErrors(errors);
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
toast.error(firstError);
return false;
}
return true;
}, [inspector, inspectionItems]);
// 검사 저장
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
return;
}
// TODO: API 호출
setShowSuccess(true);
}, [validateForm, selectedTargetId, inspectionDate, inspector, lotNo, inspectionItems, opinion]);
// 취소 - 목록으로
const handleCancel = useCallback(() => {
router.push('/ko/material/receiving-management');
}, [router]);
// 성공 다이얼로그 닫기
const handleSuccessClose = useCallback(() => {
setShowSuccess(false);
router.push('/ko/material/receiving-management');
}, [router]);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = useCallback(() => (
<>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 좌측: 검사 대상 선택 */}
<div className="lg:col-span-1 space-y-2">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2 border rounded-lg p-2 bg-white min-h-[200px]">
{isLoadingTargets ? (
<ContentSkeleton type="list" rows={3} />
) : inspectionTargets.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
.
</div>
) : (
inspectionTargets.map((target) => (
<div
key={target.id}
onClick={() => handleTargetSelect(target.id)}
className={`p-3 rounded-lg cursor-pointer border transition-colors ${
selectedTargetId === target.id
? 'bg-blue-50 border-blue-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<p className="font-medium text-sm">{target.orderNo}</p>
<p className="text-xs text-muted-foreground">
{target.supplier} · {target.orderQty ?? target.receivingQty ?? '-'} {target.unit}
</p>
</div>
))
)}
</div>
</div>
{/* 우측: 검사 정보 및 항목 */}
<div className="lg:col-span-3 space-y-6">
{/* 검사 정보 */}
<div className="space-y-4 bg-white p-4 rounded-lg border">
<h3 className="font-medium"> </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-sm text-muted-foreground"></Label>
<DatePicker
value={inspectionDate}
onChange={(date) => setInspectionDate(date)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<Input
value={inspector}
onChange={(e) => {
setInspector(e.target.value);
if (validationErrors.inspector) {
setValidationErrors((prev) => {
const { inspector: _, ...rest } = prev;
return rest;
});
}
}}
placeholder="검사자명 입력"
className={validationErrors.inspector ? 'border-red-500' : ''}
/>
{validationErrors.inspector && (
<p className="text-sm text-red-500">{validationErrors.inspector}</p>
)}
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
<Input value={lotNo} onChange={(e) => setLotNo(e.target.value)} />
</div>
</div>
</div>
{/* 검사 항목 */}
<div className="space-y-4 bg-white p-4 rounded-lg border">
<h3 className="font-medium"> </h3>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-center font-medium w-[100px]"></th>
<th className="px-3 py-2 text-left font-medium w-[120px]"></th>
</tr>
</thead>
<tbody>
{inspectionItems.map((item, index) => {
const judgmentErrorKey = `judgment_${index}`;
return (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.name}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{item.specification}
</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">
<Select
value={item.judgment || ''}
onValueChange={(value) =>
handleJudgmentChange(item.id, index, value as '적' | '부적')
}
>
<SelectTrigger className={`h-8 ${validationErrors[judgmentErrorKey] ? 'border-red-500' : ''}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="적"></SelectItem>
<SelectItem value="부적"></SelectItem>
</SelectContent>
</Select>
{validationErrors[judgmentErrorKey] && (
<p className="text-xs text-red-500 mt-1">{validationErrors[judgmentErrorKey]}</p>
)}
</td>
<td className="px-3 py-2">
<Input
value={item.remark || ''}
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
placeholder="비고"
className="h-8"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* 종합 의견 */}
<div className="space-y-2 bg-white p-4 rounded-lg border">
<Label className="text-sm font-medium"> </Label>
<Textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
placeholder="검사 관련 특이사항 입력"
rows={3}
/>
</div>
</div>
</div>
{/* 성공 다이얼로그 */}
<SuccessDialog
open={showSuccess}
type="inspection"
lotNo={lotNo}
onClose={handleSuccessClose}
/>
</>
), [
isLoadingTargets, inspectionTargets, selectedTargetId, inspectionDate,
inspector, lotNo, inspectionItems, opinion, validationErrors, showSuccess,
handleTargetSelect, handleJudgmentChange, handleRemarkChange, handleSuccessClose,
]);
return (
<IntegratedDetailTemplate
config={materialInspectionCreateConfig}
mode="create"
isLoading={isLoadingTargets}
isSubmitting={false}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
);
}