- 미사용 import/변수/console.log 대량 정리 (100+개 파일) - ItemMasterContext 간소화 (미사용 로직 제거) - IntegratedListTemplateV2 / UniversalListPage 개선 - 결재 컴포넌트(ApprovalBox, DraftBox, ReferenceBox) 정리 - HR 컴포넌트(급여/휴가/부서) 코드 간소화 - globals.css 스타일 정리 및 개선 - AuthenticatedLayout 개선 - middleware CSP 정리 - proxy route 불필요 로깅 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
364 lines
14 KiB
TypeScript
364 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 { getTodayString } from '@/utils/date';
|
||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||
import { materialInspectionCreateConfig } from './inspectionConfig';
|
||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||
import { Button } from '@/components/ui/button';
|
||
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||
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<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);
|
||
setValidationErrors([]);
|
||
}, []);
|
||
|
||
// 판정 변경 핸들러
|
||
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
|
||
setInspectionItems((prev) =>
|
||
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
||
);
|
||
setValidationErrors([]);
|
||
}, []);
|
||
|
||
// 비고 변경 핸들러
|
||
const handleRemarkChange = useCallback((itemId: string, remark: string) => {
|
||
setInspectionItems((prev) =>
|
||
prev.map((item) => (item.id === itemId ? { ...item, remark } : item))
|
||
);
|
||
}, []);
|
||
|
||
// 유효성 검사
|
||
const validateForm = useCallback((): boolean => {
|
||
const errors: string[] = [];
|
||
|
||
// 필수 필드: 검사자
|
||
if (!inspector.trim()) {
|
||
errors.push('검사자는 필수 입력 항목입니다.');
|
||
}
|
||
|
||
// 검사 항목 판정 확인
|
||
inspectionItems.forEach((item, index) => {
|
||
if (!item.judgment) {
|
||
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
|
||
}
|
||
});
|
||
|
||
setValidationErrors(errors);
|
||
return errors.length === 0;
|
||
}, [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">
|
||
{/* Validation 에러 표시 */}
|
||
{validationErrors.length > 0 && (
|
||
<Alert className="bg-red-50 border-red-200">
|
||
<AlertDescription className="text-red-900">
|
||
<div className="flex items-start gap-2">
|
||
<span className="text-lg">⚠️</span>
|
||
<div className="flex-1">
|
||
<strong className="block mb-2">
|
||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||
</strong>
|
||
<ul className="space-y-1 text-sm">
|
||
{validationErrors.map((error, index) => (
|
||
<li key={index} className="flex items-start gap-1">
|
||
<span>•</span>
|
||
<span>{error}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 검사 정보 */}
|
||
<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);
|
||
setValidationErrors([]);
|
||
}}
|
||
placeholder="검사자명 입력"
|
||
/>
|
||
</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) => (
|
||
<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, value as '적' | '부적')
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue placeholder="선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="적">적</SelectItem>
|
||
<SelectItem value="부적">부적</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</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}
|
||
/>
|
||
);
|
||
} |