feat(WEB): Phase 6 IntegratedDetailTemplate 마이그레이션 완료
Phase 6 마이그레이션 (41개 컴포넌트 완료): - 건설/시공: 협력업체, 시공관리, 기성관리, 발주관리, 계약관리 등 - 영업: 견적관리(V2), 고객관리(V2), 수주관리 - 회계: 청구관리, 매입관리, 매출관리, 거래처관리, 악성채권 등 - 생산: 작업지시, 검수관리 - 출고: 출하관리 - 자재: 입고관리, 재고현황 - 고객센터: 문의관리, 이벤트관리, 공지관리 - 인사: 직원관리 - 설정: 권한관리 주요 변경사항: - 34개 xxxConfig.ts 파일 생성 (설정 기반 페이지 구성) - PageLayout/PageHeader → IntegratedDetailTemplate 통합 - 일관된 타이틀/버튼 영역 (목록, 상세, 수정, 삭제) - 1112줄 코드 감소 (중복 제거) 프로젝트 공통화 현황 분석 문서 추가: - 상세 페이지 62%, 목록 페이지 82% 공통화 달성 - 추가 공통화 기회 및 로드맵 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,12 @@
|
||||
/**
|
||||
* 검사 상세/수정 페이지
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ClipboardCheck, Printer, Paperclip, Loader2 } from 'lucide-react';
|
||||
import { Printer, Paperclip, Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
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';
|
||||
@@ -200,217 +202,199 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
console.log('Print Report');
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
// 저장 핸들러 (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 (
|
||||
<PageLayout>
|
||||
<ContentLoadingSpinner text="검사 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
<Button variant="outline" onClick={handlePrintReport}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
성적서
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// View 모드 폼 내용 렌더링
|
||||
const renderViewContent = () => {
|
||||
if (!inspection) return null;
|
||||
|
||||
// 데이터 없음
|
||||
if (!inspection) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="검사 정보를 불러올 수 없습니다"
|
||||
message="검사 데이터를 찾을 수 없습니다."
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 상세 보기 모드
|
||||
if (!isEditMode) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">검사 상세</h1>
|
||||
<Badge variant="outline" className="text-sm">{inspection.inspectionNo}</Badge>
|
||||
{inspection.result && (
|
||||
<Badge className={`${judgmentColorMap[inspection.result]} border-0`}>
|
||||
{inspection.result}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handlePrintReport}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
성적서
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 수정 모드
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">검사 수정</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancelEdit} disabled={isSubmitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmitEdit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
수정 중...
|
||||
</>
|
||||
) : (
|
||||
'수정 완료'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 검사 정보 */}
|
||||
<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">
|
||||
@@ -546,6 +530,32 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
// 데이터 없음
|
||||
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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 검수관리 상세 페이지 Config
|
||||
*
|
||||
* 참고: 이 config는 타이틀/버튼 영역만 정의
|
||||
* 폼 내용은 기존 InspectionDetail의 renderView/renderForm에서 처리
|
||||
* (검사 데이터 테이블, 측정값 입력, validation 등 특수 기능 유지)
|
||||
*/
|
||||
export const inspectionConfig: DetailConfig = {
|
||||
title: '검사 상세',
|
||||
description: '검사 정보를 조회하고 관리합니다',
|
||||
icon: ClipboardCheck,
|
||||
basePath: '/quality/inspections',
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: false, // 검수관리는 삭제 기능 없음
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
submitLabel: '수정 완료',
|
||||
cancelLabel: '취소',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user