- UniversalListPage에 externalIsLoading prop 추가 - CardTransactionDetailClient DevFill 자동입력 기능 추가 - 여러 컴포넌트 로딩 상태 처리 개선 - skeleton 컴포넌트 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* 검사 상세/수정 페이지
|
||
* API 연동 완료 (2025-12-26)
|
||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||
*/
|
||
|
||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { Printer, Paperclip, Loader2 } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { NumberInput } from '@/components/ui/number-input';
|
||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||
import { inspectionConfig } from './inspectionConfig';
|
||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||
import { toast } from 'sonner';
|
||
import { getInspectionById, updateInspection } from './actions';
|
||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||
import { judgmentColorMap, judgeMeasurement } from './mockData';
|
||
import type { Inspection, InspectionItem, QualityCheckItem, MeasurementItem } from './types';
|
||
|
||
interface InspectionDetailProps {
|
||
id: string;
|
||
}
|
||
|
||
export function InspectionDetail({ id }: InspectionDetailProps) {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const isEditMode = searchParams.get('mode') === 'edit';
|
||
|
||
// 검사 데이터 상태
|
||
const [inspection, setInspection] = useState<Inspection | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
// 수정 폼 상태
|
||
const [editReason, setEditReason] = useState('');
|
||
const [inspectionItems, setInspectionItems] = useState<InspectionItem[]>([]);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
|
||
// validation 에러 상태
|
||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||
|
||
// API로 검사 데이터 로드
|
||
const loadInspection = useCallback(async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const result = await getInspectionById(id);
|
||
if (result.success && result.data) {
|
||
setInspection(result.data);
|
||
setInspectionItems(result.data.items || []);
|
||
} else {
|
||
toast.error(result.error || '검사 데이터를 불러오는데 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
if (isNextRedirectError(error)) throw error;
|
||
console.error('[InspectionDetail] loadInspection error:', error);
|
||
toast.error('검사 데이터 로드 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
// 컴포넌트 마운트 시 데이터 로드
|
||
useEffect(() => {
|
||
loadInspection();
|
||
}, [loadInspection]);
|
||
|
||
// 품질 검사 항목 결과 변경 (양호/불량)
|
||
const handleQualityResultChange = useCallback((itemId: string, result: '양호' | '불량') => {
|
||
setInspectionItems(prev => prev.map(item => {
|
||
if (item.id === itemId && item.type === 'quality') {
|
||
return {
|
||
...item,
|
||
result,
|
||
judgment: result === '양호' ? '적합' : '부적합',
|
||
} as QualityCheckItem;
|
||
}
|
||
return item;
|
||
}));
|
||
// 입력 시 에러 클리어
|
||
setValidationErrors([]);
|
||
}, []);
|
||
|
||
// 측정 항목 값 변경
|
||
const handleMeasurementChange = useCallback((itemId: string, value: string) => {
|
||
setInspectionItems(prev => prev.map(item => {
|
||
if (item.id === itemId && item.type === 'measurement') {
|
||
const measuredValue = parseFloat(value) || 0;
|
||
const judgment = judgeMeasurement(item.spec, measuredValue);
|
||
return {
|
||
...item,
|
||
measuredValue,
|
||
judgment,
|
||
} as MeasurementItem;
|
||
}
|
||
return item;
|
||
}));
|
||
// 입력 시 에러 클리어
|
||
setValidationErrors([]);
|
||
}, []);
|
||
|
||
// 목록으로
|
||
const handleBack = () => {
|
||
router.push('/quality/inspections');
|
||
};
|
||
|
||
// 수정 모드 진입
|
||
const handleEditMode = () => {
|
||
router.push(`/quality/inspections/${id}?mode=edit`);
|
||
};
|
||
|
||
// 수정 취소
|
||
const handleCancelEdit = () => {
|
||
router.push(`/quality/inspections/${id}`);
|
||
};
|
||
|
||
// validation 체크
|
||
const validateForm = (): boolean => {
|
||
const errors: string[] = [];
|
||
|
||
// 필수 필드: 수정 사유
|
||
if (!editReason.trim()) {
|
||
errors.push('수정 사유는 필수 입력 항목입니다.');
|
||
}
|
||
|
||
// 검사 항목 validation
|
||
inspectionItems.forEach((item, index) => {
|
||
if (item.type === 'quality') {
|
||
const qualityItem = item as QualityCheckItem;
|
||
if (!qualityItem.result) {
|
||
errors.push(`${index + 1}. ${item.name}: 결과를 선택해주세요.`);
|
||
}
|
||
} else if (item.type === 'measurement') {
|
||
const measurementItem = item as MeasurementItem;
|
||
if (measurementItem.measuredValue === undefined || measurementItem.measuredValue === null) {
|
||
errors.push(`${index + 1}. ${item.name}: 측정값을 입력해주세요.`);
|
||
}
|
||
}
|
||
});
|
||
|
||
setValidationErrors(errors);
|
||
return errors.length === 0;
|
||
};
|
||
|
||
// 수정 완료
|
||
const handleSubmitEdit = async () => {
|
||
// validation 체크
|
||
if (!validateForm()) {
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const result = await updateInspection(id, {
|
||
items: inspectionItems,
|
||
remarks: editReason,
|
||
});
|
||
|
||
if (result.success) {
|
||
toast.success('검사가 수정되었습니다.');
|
||
router.push(`/quality/inspections/${id}`);
|
||
} else {
|
||
toast.error(result.error || '검사 수정에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
if (isNextRedirectError(error)) throw error;
|
||
console.error('[InspectionDetail] handleSubmitEdit error:', error);
|
||
toast.error('검사 수정 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// 수정 사유 변경 핸들러
|
||
const handleEditReasonChange = (value: string) => {
|
||
setEditReason(value);
|
||
// 입력 시 에러 클리어
|
||
if (validationErrors.length > 0) {
|
||
setValidationErrors([]);
|
||
}
|
||
};
|
||
|
||
// 성적서 출력
|
||
const handlePrintReport = () => {
|
||
// TODO: 성적서 출력 기능
|
||
console.log('Print Report');
|
||
};
|
||
|
||
// 저장 핸들러 (IntegratedDetailTemplate용)
|
||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||
// validation 체크
|
||
if (!validateForm()) {
|
||
return { success: false, error: '입력 내용을 확인해주세요.' };
|
||
}
|
||
|
||
try {
|
||
const result = await updateInspection(id, {
|
||
items: inspectionItems,
|
||
remarks: editReason,
|
||
});
|
||
|
||
if (result.success) {
|
||
toast.success('검사가 수정되었습니다.');
|
||
router.push(`/quality/inspections/${id}`);
|
||
return { success: true };
|
||
}
|
||
return { success: false, error: result.error || '검사 수정에 실패했습니다.' };
|
||
} catch (error) {
|
||
if (isNextRedirectError(error)) throw error;
|
||
return { success: false, error: '검사 수정 중 오류가 발생했습니다.' };
|
||
}
|
||
}, [id, inspectionItems, editReason, router, validateForm]);
|
||
|
||
// 모드 결정
|
||
const mode = isEditMode ? 'edit' : 'view';
|
||
|
||
// 동적 config (모드에 따른 타이틀 변경)
|
||
const dynamicConfig = useMemo(() => {
|
||
if (isEditMode) {
|
||
return {
|
||
...inspectionConfig,
|
||
title: '검사 수정',
|
||
};
|
||
}
|
||
return inspectionConfig;
|
||
}, [isEditMode]);
|
||
|
||
// 커스텀 헤더 액션 (view 모드에서 성적서 버튼)
|
||
const customHeaderActions = useMemo(() => {
|
||
if (isEditMode) return null;
|
||
return (
|
||
<Button variant="outline" onClick={handlePrintReport}>
|
||
<Printer className="w-4 h-4 mr-1.5" />
|
||
성적서
|
||
</Button>
|
||
);
|
||
}, [isEditMode]);
|
||
|
||
// View 모드 폼 내용 렌더링
|
||
const renderViewContent = () => {
|
||
if (!inspection) return null;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 검사 정보 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">검사 정보</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">검사번호</Label>
|
||
<p className="font-medium">{inspection.inspectionNo}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">검사유형</Label>
|
||
<p className="font-medium">{inspection.inspectionType}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">검사일자</Label>
|
||
<p className="font-medium">{inspection.inspectionDate || '-'}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">판정결과</Label>
|
||
<p className="font-medium">
|
||
{inspection.result && (
|
||
<Badge className={`${judgmentColorMap[inspection.result]} border-0`}>
|
||
{inspection.result}
|
||
</Badge>
|
||
)}
|
||
{!inspection.result && '-'}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">품목명</Label>
|
||
<p className="font-medium">{inspection.itemName}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">LOT NO</Label>
|
||
<p className="font-medium">{inspection.lotNo}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">공정명</Label>
|
||
<p className="font-medium">{inspection.processName}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground text-xs">검사자</Label>
|
||
<p className="font-medium">{inspection.inspector || '-'}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 검사 결과 데이터 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">검사 결과 데이터</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[150px]">항목명</TableHead>
|
||
<TableHead className="w-[150px]">기준(Spec)</TableHead>
|
||
<TableHead className="w-[150px]">측정값/결과</TableHead>
|
||
<TableHead className="w-[100px]">판정</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{inspection.items.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell className="font-medium">{item.name}</TableCell>
|
||
<TableCell>{item.spec}</TableCell>
|
||
<TableCell>
|
||
{item.type === 'quality'
|
||
? (item as QualityCheckItem).result || '-'
|
||
: `${(item as MeasurementItem).measuredValue || '-'} ${(item as MeasurementItem).unit}`
|
||
}
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className={item.judgment ? judgmentColorMap[item.judgment] : ''}>
|
||
{item.judgment || '-'}
|
||
</span>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{inspection.items.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||
검사 데이터가 없습니다.
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 종합 의견 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">종합 의견</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm">{inspection.opinion || '의견이 없습니다.'}</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 첨부 파일 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">첨부 파일</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{inspection.attachments && inspection.attachments.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{inspection.attachments.map((file) => (
|
||
<div key={file.id} className="flex items-center gap-2 text-sm">
|
||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||
<a href={file.fileUrl} className="text-blue-600 hover:underline">
|
||
{file.fileName}
|
||
</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">첨부 파일이 없습니다.</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Edit 모드 폼 내용 렌더링
|
||
const renderFormContent = () => {
|
||
if (!inspection) return null;
|
||
|
||
return (
|
||
<div className="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>
|
||
)}
|
||
|
||
{/* 검사 개요 (수정 불가) */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">검사 개요 (수정 불가)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">LOT NO (비활성)</Label>
|
||
<Input value={inspection.lotNo} disabled className="bg-muted" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">품목명 (비활성)</Label>
|
||
<Input value={inspection.itemName} disabled className="bg-muted" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">공정명 (비활성)</Label>
|
||
<Input value={inspection.processName} disabled className="bg-muted" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">수량 (비활성)</Label>
|
||
<Input value={`${inspection.quantity} ${inspection.unit}`} disabled className="bg-muted" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 수정 사유 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
수정 사유 (필수 <span className="text-red-500">★</span>)
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Textarea
|
||
value={editReason}
|
||
onChange={(e) => handleEditReasonChange(e.target.value)}
|
||
placeholder="수정 사유를 입력하세요 (예: 오기입 수정, 높이 측정값 입력 오류)"
|
||
className="min-h-[100px]"
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 검사 데이터 수정 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">검사 데이터 수정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{inspectionItems.map((item, index) => (
|
||
<div key={item.id} className="p-4 border rounded-lg">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h4 className="font-medium">
|
||
{index + 1}. {item.name}
|
||
{item.type === 'measurement' && ` (${(item as MeasurementItem).unit})`}
|
||
</h4>
|
||
<span className={`text-sm font-medium ${
|
||
item.judgment === '적합' ? 'text-green-600' :
|
||
item.judgment === '부적합' ? 'text-red-600' :
|
||
'text-muted-foreground'
|
||
}`}>
|
||
판정: {item.judgment || '-'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">기준(Spec)</Label>
|
||
<Input value={item.spec} disabled className="bg-muted" />
|
||
</div>
|
||
|
||
{item.type === 'quality' ? (
|
||
<div className="space-y-2">
|
||
<Label>결과 입력 *</Label>
|
||
<RadioGroup
|
||
value={(item as QualityCheckItem).result || ''}
|
||
onValueChange={(value) => handleQualityResultChange(item.id, value as '양호' | '불량')}
|
||
className="flex items-center gap-4 h-10"
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="양호" id={`${item.id}-pass`} />
|
||
<Label htmlFor={`${item.id}-pass`} className="cursor-pointer">양호</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="불량" id={`${item.id}-fail`} />
|
||
<Label htmlFor={`${item.id}-fail`} className="cursor-pointer">불량</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<Label>측정값 입력 ({(item as MeasurementItem).unit}) *</Label>
|
||
<NumberInput
|
||
step={0.1}
|
||
allowDecimal
|
||
value={(item as MeasurementItem).measuredValue ?? undefined}
|
||
onChange={(value) => handleMeasurementChange(item.id, String(value ?? ''))}
|
||
placeholder={`측정값 입력 (${(item as MeasurementItem).unit})`}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
※ (16.6으로 잘못 연결 16.6으로 수정)
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 데이터 없음
|
||
if (!isLoading && !inspection) {
|
||
return (
|
||
<ServerErrorPage
|
||
title="검사 정보를 불러올 수 없습니다"
|
||
message="검사 데이터를 찾을 수 없습니다."
|
||
showBackButton={true}
|
||
showHomeButton={true}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<IntegratedDetailTemplate
|
||
config={dynamicConfig}
|
||
mode={mode}
|
||
initialData={{}}
|
||
itemId={id}
|
||
isLoading={isLoading}
|
||
onSubmit={handleSubmit}
|
||
headerActions={customHeaderActions}
|
||
renderView={() => renderViewContent()}
|
||
renderForm={() => renderFormContent()}
|
||
/>
|
||
);
|
||
}
|