Files
sam-react-prod/src/components/quality/InspectionManagement/InspectionDetail.tsx
유병철 19237be4aa refactor: UniversalListPage externalIsLoading 지원 및 스켈레톤 개선
- UniversalListPage에 externalIsLoading prop 추가
- CardTransactionDetailClient DevFill 자동입력 기능 추가
- 여러 컴포넌트 로딩 상태 처리 개선
- skeleton 컴포넌트 확장

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

562 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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()}
/>
);
}