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:
유병철
2026-01-20 15:51:02 +09:00
parent 6f457b28f3
commit 61e3a0ed60
71 changed files with 4743 additions and 4402 deletions

View File

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

View File

@@ -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: '취소',
},
};