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,12 +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 } from 'next/navigation';
import {
Truck,
FileText,
Receipt,
ClipboardList,
@@ -16,10 +16,7 @@ import {
Printer,
X,
Loader2,
AlertCircle,
Trash2,
} from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -36,19 +33,11 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { PageLayout } from '@/components/organisms/PageLayout';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { shipmentConfig } from './shipmentConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { toast } from 'sonner';
import { getShipmentById, deleteShipment, updateShipmentStatus } from './actions';
import {
SHIPMENT_STATUS_LABELS,
@@ -71,8 +60,6 @@ interface ShipmentDetailProps {
export function ShipmentDetail({ id }: ShipmentDetailProps) {
const router = useRouter();
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'transaction' | 'delivery' | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// API 데이터 상태
const [detail, setDetail] = useState<ShipmentDetailType | null>(null);
@@ -106,33 +93,19 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
loadData();
}, [loadData]);
// 목록으로 이동
const handleGoBack = useCallback(() => {
router.push('/ko/outbound/shipments');
}, [router]);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
router.push(`/ko/outbound/shipments/${id}/edit`);
}, [id, router]);
// 삭제 처리
const handleDelete = useCallback(async () => {
setIsDeleting(true);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
const result = await deleteShipment(id);
if (result.success) {
toast.success('출하 정보가 삭제되었습니다.');
router.push('/ko/outbound/shipments');
} else {
alert(result.error || '삭제에 실패했습니다.');
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentDetail] handleDelete error:', err);
alert('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [id, router]);
@@ -144,6 +117,22 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
printArea({ title: `${docName} 인쇄` });
}, [previewDocument]);
// 수정/삭제 가능 여부 (scheduled, ready 상태에서만)
const canEdit = detail?.status === 'scheduled' || detail?.status === 'ready';
const canDelete = detail?.status === 'scheduled' || detail?.status === 'ready';
// 동적 config (상태에 따른 삭제 버튼 표시 여부)
const dynamicConfig = useMemo(() => {
return {
...shipmentConfig,
actions: {
...shipmentConfig.actions,
showDelete: canDelete,
showEdit: canEdit,
},
};
}, [canDelete, canEdit]);
// 정보 영역 렌더링
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
<div className={className}>
@@ -152,89 +141,44 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</div>
);
// 로딩 상태 표시
if (isLoading) {
// 커스텀 헤더 액션 (문서 미리보기 버튼들)
const customHeaderActions = useMemo(() => {
return (
<PageLayout>
<ContentLoadingSpinner text="출하 정보를 불러오는 중..." />
</PageLayout>
<>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('shipping')}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('transaction')}
>
<Receipt className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('delivery')}
>
<ClipboardList className="w-4 h-4 mr-1" />
</Button>
</>
);
}
}, []);
// 폼 내용 렌더링
const renderFormContent = () => {
if (!detail) return null;
// 에러 상태 표시
if (error || !detail) {
return (
<ServerErrorPage
title="출하 정보를 불러올 수 없습니다"
message={error || '출하 정보를 찾을 수 없습니다.'}
showBackButton={true}
showHomeButton={true}
/>
);
}
// 수정/삭제 가능 여부 (scheduled, ready 상태에서만)
const canEdit = detail.status === 'scheduled' || detail.status === 'ready';
const canDelete = detail.status === 'scheduled' || detail.status === 'ready';
return (
<PageLayout>
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" />
<h1 className="text-xl font-semibold"> </h1>
</div>
<div className="flex items-center gap-2">
{/* 문서 미리보기 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('shipping')}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('transaction')}
>
<Receipt className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('delivery')}
>
<ClipboardList className="w-4 h-4 mr-1" />
</Button>
<div className="w-px h-6 bg-border mx-2" />
<Button variant="outline" onClick={handleGoBack}>
</Button>
{canEdit && (
<Button onClick={handleEdit}>
</Button>
)}
{canDelete && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
{/* 메인 콘텐츠 */}
<div className="space-y-6">
{/* 출고 정보 */}
<Card>
<CardHeader>
@@ -408,10 +352,38 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</CardContent>
</Card>
)}
</div>
);
};
</div>
// 에러 상태 표시
if (!isLoading && (error || !detail)) {
return (
<ServerErrorPage
title="출하 정보를 불러올 수 없습니다"
message={error || '출하 정보를 찾을 수 없습니다.'}
showBackButton={true}
showHomeButton={true}
/>
);
}
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode="view"
initialData={{}}
itemId={id}
isLoading={isLoading}
onDelete={canDelete ? handleDelete : undefined}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
{detail && (
<Dialog open={previewDocument !== null} onOpenChange={() => setPreviewDocument(null)}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
{/* 접근성을 위한 숨겨진 타이틀 */}
@@ -462,38 +434,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</div>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{detail.shipmentNo}() ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</PageLayout>
)}
</>
);
}

View File

@@ -0,0 +1,35 @@
import { Truck } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 출하관리 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 기존 ShipmentDetail의 renderView에서 처리
* (문서 미리보기 모달, 상태에 따른 수정/삭제 가능 여부 등 특수 기능 유지)
*
* 특이사항:
* - view 모드만 지원 (수정은 별도 /edit 페이지로 이동)
* - 삭제 기능 있음 (scheduled, ready 상태에서만)
* - 문서 미리보기: 출고증, 거래명세서, 납품확인서
*/
export const shipmentConfig: DetailConfig = {
title: '출하 상세',
description: '출하 정보를 조회하고 관리합니다',
icon: Truck,
basePath: '/outbound/shipments',
fields: [], // renderView 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: true, // 상태에 따라 동적으로 처리
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
deleteConfirmMessage: {
title: '출하 정보 삭제',
description: '이 출하 정보를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
},
};