feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용

Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금

Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입

신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들

총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-19 17:31:28 +09:00
parent 1a6cde2d36
commit 1d7b028693
109 changed files with 6811 additions and 2562 deletions

View File

@@ -0,0 +1,201 @@
'use client';
/**
* 팝업관리 상세 클라이언트 컴포넌트 V2
* IntegratedDetailTemplate 기반 마이그레이션
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { format } from 'date-fns';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData } from './types';
import { getPopupById, createPopup, updatePopup, deletePopup } from './actions';
import { popupDetailConfig } from './popupDetailConfig';
import { toast } from 'sonner';
interface PopupDetailClientV2Props {
popupId?: string;
initialMode?: DetailMode;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !popupId || popupId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [popupData, setPopupData] = useState<Popup | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const data = await getPopupById(popupId!);
if (data) {
setPopupData(data);
} else {
toast.error('팝업을 불러오는데 실패했습니다.');
router.push(popupDetailConfig.basePath);
}
} catch (error) {
console.error('팝업 조회 실패:', error);
toast.error('팝업을 불러오는데 실패했습니다.');
router.push(popupDetailConfig.basePath);
} finally {
setIsLoading(false);
}
};
loadData();
}, [popupId, isNewMode, router]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
setMode(newMode);
if (newMode === 'edit' && popupId) {
router.push(`${popupDetailConfig.basePath}/${popupId}?mode=edit`);
} else if (newMode === 'view' && popupId) {
router.push(`${popupDetailConfig.basePath}/${popupId}`);
}
},
[router, popupId]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const popupFormData: PopupFormData = {
target: (formData.target as PopupFormData['target']) || 'all',
title: formData.title as string,
content: formData.content as string,
status: (formData.status as PopupFormData['status']) || 'inactive',
startDate: formData.startDate as string,
endDate: formData.endDate as string,
};
if (isNewMode) {
const result = await createPopup(popupFormData);
if (result.success) {
toast.success('팝업이 등록되었습니다.');
router.push(popupDetailConfig.basePath);
return { success: true };
}
return { success: false, error: result.error || '팝업 등록에 실패했습니다.' };
} else {
const result = await updatePopup(popupId!, popupFormData);
if (result.success) {
toast.success('팝업이 수정되었습니다.');
router.push(`${popupDetailConfig.basePath}/${popupId}`);
return { success: true };
}
return { success: false, error: result.error || '팝업 수정에 실패했습니다.' };
}
} catch (error) {
console.error('저장 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
}
},
[isNewMode, popupId, router]
);
// 삭제 핸들러
const handleDelete = useCallback(
async (id: string | number) => {
try {
const result = await deletePopup(String(id));
if (result.success) {
toast.success('팝업이 삭제되었습니다.');
router.push(popupDetailConfig.basePath);
return { success: true };
}
return { success: false, error: result.error || '팝업 삭제에 실패했습니다.' };
} catch (error) {
console.error('삭제 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
}
},
[router]
);
// 취소 핸들러
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push(popupDetailConfig.basePath);
} else {
setMode('view');
router.push(`${popupDetailConfig.basePath}/${popupId}`);
}
}, [router, popupId, isNewMode]);
// 초기 데이터 (신규 등록 시 기본값 포함)
const initialData = isNewMode
? ({
target: 'all',
status: 'inactive',
author: CURRENT_USER.name,
createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'),
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
} as unknown as Popup)
: popupData || undefined;
// 타이틀 동적 설정
const dynamicConfig = {
...popupDetailConfig,
title:
mode === 'create'
? '팝업관리'
: mode === 'edit'
? popupData?.title || '팝업관리'
: popupData?.title || '팝업관리 상세',
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={mode}
initialData={initialData}
itemId={popupId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onCancel={handleCancel}
onModeChange={handleModeChange}
/>
);
}

View File

@@ -1,4 +1,5 @@
export { PopupList } from './PopupList';
export { PopupForm } from './PopupForm';
export { PopupDetail } from './PopupDetail';
export { PopupDetailClientV2 } from './PopupDetailClientV2';
export * from './types';

View File

@@ -0,0 +1,191 @@
/**
* 팝업관리 상세 페이지 설정
* IntegratedDetailTemplate V2 마이그레이션
*/
import { Megaphone } from 'lucide-react';
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
import { RichTextEditor } from '@/components/board/RichTextEditor';
import { createElement } from 'react';
// ===== 대상 옵션 =====
const TARGET_OPTIONS = [
{ value: 'all', label: '전사' },
{ value: 'department', label: '부서별' },
];
// ===== 상태 옵션 =====
const STATUS_OPTIONS = [
{ value: 'inactive', label: '사용안함' },
{ value: 'active', label: '사용함' },
];
// ===== 필드 정의 =====
export const popupFields: FieldDefinition[] = [
{
key: 'target',
label: '대상',
type: 'select',
required: true,
options: TARGET_OPTIONS,
placeholder: '대상을 선택해주세요',
validation: [
{ type: 'required', message: '대상을 선택해주세요.' },
],
},
{
key: 'startDate',
label: '시작일',
type: 'date',
required: true,
validation: [
{ type: 'required', message: '시작일을 선택해주세요.' },
],
},
{
key: 'endDate',
label: '종료일',
type: 'date',
required: true,
validation: [
{ type: 'required', message: '종료일을 선택해주세요.' },
{
type: 'custom',
message: '종료일은 시작일 이후여야 합니다.',
validate: (value, formData) => {
const startDate = formData.startDate as string;
const endDate = value as string;
if (!startDate || !endDate) return true;
return endDate >= startDate;
},
},
],
},
{
key: 'title',
label: '제목',
type: 'text',
required: true,
placeholder: '제목을 입력해주세요',
gridSpan: 2,
validation: [
{ type: 'required', message: '제목을 입력해주세요.' },
],
},
{
key: 'content',
label: '내용',
type: 'custom',
required: true,
gridSpan: 2,
validation: [
{
type: 'custom',
message: '내용을 입력해주세요.',
validate: (value) => {
const content = value as string;
return !!content && content.trim() !== '' && content !== '<p></p>';
},
},
],
renderField: ({ value, onChange, mode, disabled }) => {
if (mode === 'view') {
// View 모드: HTML 렌더링
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: (value as string) || '' },
});
}
// Edit/Create 모드: RichTextEditor
return createElement(RichTextEditor, {
value: (value as string) || '',
onChange: onChange,
placeholder: '내용을 입력해주세요',
minHeight: '200px',
disabled: disabled,
});
},
formatValue: (value) => {
if (!value) return '-';
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: value as string },
});
},
},
{
key: 'status',
label: '상태',
type: 'radio',
options: STATUS_OPTIONS,
defaultValue: 'inactive',
},
{
key: 'author',
label: '작성자',
type: 'text',
disabled: true,
hideInForm: false,
},
{
key: 'createdAt',
label: '등록일시',
type: 'text',
disabled: true,
hideInForm: false,
},
];
// ===== 섹션 정의 =====
export const popupSections: SectionDefinition[] = [
{
id: 'basicInfo',
title: '팝업 정보',
description: '팝업의 기본 정보를 입력해주세요',
fields: ['target', 'startDate', 'endDate', 'title', 'content', 'status', 'author', 'createdAt'],
},
];
// ===== 설정 =====
export const popupDetailConfig: DetailConfig<Popup> = {
title: '팝업관리',
description: '팝업 목록을 관리합니다',
icon: Megaphone,
basePath: '/ko/settings/popup-management',
fields: popupFields,
sections: popupSections,
gridColumns: 2,
actions: {
submitLabel: '저장',
cancelLabel: '취소',
showDelete: true,
deleteLabel: '삭제',
showEdit: true,
editLabel: '수정',
showBack: true,
backLabel: '목록',
deleteConfirmMessage: {
title: '팝업 삭제',
description: '이 팝업을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Popup) => ({
target: data.target || 'all',
startDate: data.startDate || '',
endDate: data.endDate || '',
title: data.title || '',
content: data.content || '',
status: data.status || 'inactive',
author: data.author || '',
createdAt: data.createdAt || '',
}),
transformSubmitData: (formData): Partial<PopupFormData> => ({
target: formData.target as PopupTarget,
title: formData.title as string,
content: formData.content as string,
status: formData.status as PopupStatus,
startDate: formData.startDate as string,
endDate: formData.endDate as string,
}),
};