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

@@ -2,23 +2,19 @@
/**
* 이벤트 상세 컴포넌트
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*
* 디자인 스펙:
* - 페이지 타이틀: 이벤트 상세
* - 이벤트 / 제목
* - 작성자 | 기간 | 조회수
* - 이미지 영역
* - 내용
* - 첨부파일
* 특이사항:
* - view 모드만 지원 (수정/삭제 없음)
* - 이미지, 첨부파일 표시
*/
import { useRouter } from 'next/navigation';
import { Calendar, ArrowLeft, Download } from 'lucide-react';
import { useCallback } from 'react';
import { Download } from 'lucide-react';
import Image from 'next/image';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { eventConfig } from './eventConfig';
import { type Event } from './types';
interface EventDetailProps {
@@ -26,21 +22,9 @@ interface EventDetailProps {
}
export function EventDetail({ event }: EventDetailProps) {
const router = useRouter();
const handleBack = () => {
router.push('/ko/customer-center/events');
};
return (
<PageLayout>
<PageHeader
title="이벤트 상세"
description="이벤트를 조회합니다"
icon={Calendar}
/>
<Card>
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => (
<Card>
<CardContent className="p-6">
{/* 헤더: 이벤트 / 제목 */}
<div className="border-b pb-4 mb-4">
@@ -101,15 +85,17 @@ export function EventDetail({ event }: EventDetailProps) {
)}
</CardContent>
</Card>
), [event]);
{/* 목록으로 버튼 */}
<div className="flex items-center">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</div>
</PageLayout>
return (
<IntegratedDetailTemplate
config={eventConfig}
mode="view"
initialData={event}
itemId={event.id}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { Calendar } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 이벤트 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 renderView/renderForm에서 처리
*
* 특이사항:
* - view 모드만 지원 (수정/삭제 없음)
* - 이미지, 첨부파일 표시
*/
export const eventConfig: DetailConfig = {
title: '이벤트 상세',
description: '이벤트를 조회합니다',
icon: Calendar,
basePath: '/customer-center/events',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 1,
actions: {
showBack: true,
showDelete: false,
showEdit: false,
backLabel: '목록으로',
},
};

View File

@@ -2,10 +2,9 @@
/**
* 1:1 문의 상세 컴포넌트
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*
* 디자인 스펙:
* - 페이지 타이틀: 1:1 문의 상세
* - 페이지 설명: 1:1 문의를 조회합니다.
* 특이사항:
* - 삭제/수정 버튼 (답변완료 후에는 수정 버튼 비활성화)
* - 문의 영역 (제목, 작성자|날짜, 내용, 첨부파일)
* - 답변 영역 (작성자|날짜, 내용, 첨부파일)
@@ -13,28 +12,18 @@
* - 댓글 목록 (프로필, 이름, 내용, 날짜, 수정/삭제)
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { MessageSquare, Download, ArrowLeft, Edit, Trash2, User } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Download, Edit, Trash2, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Card,
CardContent,
} from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { inquiryConfig } from './inquiryConfig';
import type { Inquiry, Reply, Comment, Attachment } from './types';
interface InquiryDetailProps {
@@ -59,7 +48,6 @@ export function InquiryDetail({
onDeleteInquiry,
}: InquiryDetailProps) {
const router = useRouter();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [newComment, setNewComment] = useState('');
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editingContent, setEditingContent] = useState('');
@@ -68,28 +56,30 @@ export function InquiryDetail({
const isMyInquiry = inquiry.authorId === currentUserId;
const canEdit = isMyInquiry && inquiry.status !== 'completed';
// ===== 액션 핸들러 =====
const handleBack = useCallback(() => {
router.push('/ko/customer-center/qna');
}, [router]);
// 동적 config (작성자/상태에 따라 버튼 표시)
const dynamicConfig = useMemo(() => ({
...inquiryConfig,
actions: {
...inquiryConfig.actions,
showDelete: isMyInquiry,
showEdit: canEdit,
},
}), [isMyInquiry, canEdit]);
const handleEdit = useCallback(() => {
// 수정 버튼 클릭 시 수정 페이지로 이동
const handleEditClick = useCallback(() => {
router.push(`/ko/customer-center/qna/${inquiry.id}?mode=edit`);
}, [router, inquiry.id]);
const handleConfirmDelete = useCallback(async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
const success = await onDeleteInquiry();
if (success) {
setShowDeleteDialog(false);
router.push('/ko/customer-center/qna');
}
} finally {
setIsSubmitting(false);
// onDelete 핸들러 (Promise 반환)
const handleFormDelete = useCallback(async () => {
const success = await onDeleteInquiry();
if (success) {
router.push('/ko/customer-center/qna');
return { success: true };
}
}, [onDeleteInquiry, router, isSubmitting]);
return { success: false, error: '삭제에 실패했습니다.' };
}, [onDeleteInquiry, router]);
// ===== 댓글 핸들러 =====
const handleAddComment = useCallback(async () => {
@@ -168,39 +158,11 @@ export function InquiryDetail({
);
};
return (
<PageLayout>
{/* 헤더 */}
<PageHeader
title="1:1 문의 상세"
description="1:1 문의를 조회합니다."
icon={MessageSquare}
actions={
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
{isMyInquiry && (
<>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} disabled={!canEdit}>
</Button>
</>
)}
</div>
}
/>
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* 문의 카드 */}
<Card className="mb-6">
<Card>
<CardContent className="pt-6 space-y-4">
{/* 섹션 타이틀 */}
<div className="flex items-center gap-2">
@@ -231,7 +193,7 @@ export function InquiryDetail({
{/* 답변 카드 */}
{reply && (
<Card className="mb-6">
<Card>
<CardContent className="pt-6 space-y-4">
{/* 섹션 타이틀 */}
<div className="flex items-center gap-2">
@@ -259,7 +221,7 @@ export function InquiryDetail({
)}
{/* 댓글 등록 */}
<Card className="mb-6">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
@@ -360,28 +322,36 @@ export function InquiryDetail({
</CardContent>
</Card>
)}
</div>
), [
inquiry,
reply,
comments,
currentUserId,
newComment,
editingCommentId,
editingContent,
isSubmitting,
renderAttachments,
handleAddComment,
handleStartEdit,
handleCancelEdit,
handleSaveEdit,
handleDeleteComment,
]);
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode="view"
initialData={inquiry}
itemId={inquiry.id}
isLoading={isSubmitting}
onDelete={handleFormDelete}
onEdit={handleEditClick}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,31 @@
import { MessageSquare } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 1:1 문의 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 renderView/renderForm에서 처리
*
* 특이사항:
* - view 모드만 지원 (수정은 별도 InquiryForm 사용)
* - 댓글 CRUD 기능
* - 작성자만 삭제/수정 가능
* - 답변완료 후 수정 불가
*/
export const inquiryConfig: DetailConfig = {
title: '1:1 문의 상세',
description: '1:1 문의를 조회합니다.',
icon: MessageSquare,
basePath: '/customer-center/qna',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 1,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
editLabel: '수정',
deleteLabel: '삭제',
},
};

View File

@@ -2,23 +2,19 @@
/**
* 공지사항 상세 컴포넌트
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*
* 디자인 스펙:
* - 페이지 타이틀: 공지사항 상세
* - 공지사항 / 제목
* - 작성자 | 날짜 | 조회수
* - 이미지 영역
* - 내용
* - 첨부파일
* 특이사항:
* - view 모드만 지원 (수정/삭제 없음)
* - 이미지, 첨부파일 표시
*/
import { useRouter } from 'next/navigation';
import { Bell, ArrowLeft, Download } from 'lucide-react';
import { useCallback } from 'react';
import { Download } from 'lucide-react';
import Image from 'next/image';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { noticeConfig } from './noticeConfig';
import { type Notice } from './types';
interface NoticeDetailProps {
@@ -26,90 +22,80 @@ interface NoticeDetailProps {
}
export function NoticeDetail({ notice }: NoticeDetailProps) {
const router = useRouter();
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => (
<Card>
<CardContent className="p-6">
{/* 헤더: 공지사항 / 제목 */}
<div className="border-b pb-4 mb-4">
<div className="text-sm text-muted-foreground mb-1"></div>
<h2 className="text-xl font-semibold">{notice.title}</h2>
</div>
const handleBack = () => {
router.push('/ko/customer-center/notices');
};
{/* 메타 정보: 작성자 | 날짜 | 조회수 */}
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-6">
<span>{notice.author}</span>
<span>|</span>
<span>{notice.createdAt}</span>
<span className="ml-auto"> {notice.viewCount}</span>
</div>
{/* 이미지 영역 */}
{notice.imageUrl ? (
<div className="border rounded-md p-4 mb-4 bg-muted/30">
<div className="relative w-full aspect-video">
<Image
src={notice.imageUrl}
alt={notice.title}
fill
className="object-contain"
/>
</div>
</div>
) : (
<div className="border rounded-md p-4 mb-4 bg-muted/30 flex items-center justify-center min-h-[200px]">
<span className="text-muted-foreground">IMG</span>
</div>
)}
{/* 내용 */}
<div className="mb-6">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="prose prose-sm max-w-none">
{notice.content}
</div>
</div>
{/* 첨부파일 */}
{notice.attachments && notice.attachments.length > 0 && (
<div className="border-t pt-4">
{notice.attachments.map((file) => (
<div key={file.id} className="flex items-center gap-2">
<Download className="h-4 w-4 text-muted-foreground" />
<a
href={file.url}
className="text-sm text-blue-600 hover:underline"
download
>
{file.name}
</a>
</div>
))}
</div>
)}
</CardContent>
</Card>
), [notice]);
return (
<PageLayout>
<PageHeader
title="공지사항 상세"
description="공지사항을 조회합니다"
icon={Bell}
/>
<Card>
<CardContent className="p-6">
{/* 헤더: 공지사항 / 제목 */}
<div className="border-b pb-4 mb-4">
<div className="text-sm text-muted-foreground mb-1"></div>
<h2 className="text-xl font-semibold">{notice.title}</h2>
</div>
{/* 메타 정보: 작성자 | 날짜 | 조회수 */}
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-6">
<span>{notice.author}</span>
<span>|</span>
<span>{notice.createdAt}</span>
<span className="ml-auto"> {notice.viewCount}</span>
</div>
{/* 이미지 영역 */}
{notice.imageUrl ? (
<div className="border rounded-md p-4 mb-4 bg-muted/30">
<div className="relative w-full aspect-video">
<Image
src={notice.imageUrl}
alt={notice.title}
fill
className="object-contain"
/>
</div>
</div>
) : (
<div className="border rounded-md p-4 mb-4 bg-muted/30 flex items-center justify-center min-h-[200px]">
<span className="text-muted-foreground">IMG</span>
</div>
)}
{/* 내용 */}
<div className="mb-6">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="prose prose-sm max-w-none">
{notice.content}
</div>
</div>
{/* 첨부파일 */}
{notice.attachments && notice.attachments.length > 0 && (
<div className="border-t pt-4">
{notice.attachments.map((file) => (
<div key={file.id} className="flex items-center gap-2">
<Download className="h-4 w-4 text-muted-foreground" />
<a
href={file.url}
className="text-sm text-blue-600 hover:underline"
download
>
{file.name}
</a>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 목록으로 버튼 */}
<div className="flex items-center">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</div>
</PageLayout>
<IntegratedDetailTemplate
config={noticeConfig}
mode="view"
initialData={notice}
itemId={notice.id}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { Bell } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 공지사항 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 renderView/renderForm에서 처리
*
* 특이사항:
* - view 모드만 지원 (수정/삭제 없음)
* - 이미지, 첨부파일 표시
*/
export const noticeConfig: DetailConfig = {
title: '공지사항 상세',
description: '공지사항을 조회합니다',
icon: Bell,
basePath: '/customer-center/notices',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 1,
actions: {
showBack: true,
showDelete: false,
showEdit: false,
backLabel: '목록으로',
},
};