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:
201
src/components/settings/PopupManagement/PopupDetailClientV2.tsx
Normal file
201
src/components/settings/PopupManagement/PopupDetailClientV2.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { PopupList } from './PopupList';
|
||||
export { PopupForm } from './PopupForm';
|
||||
export { PopupDetail } from './PopupDetail';
|
||||
export { PopupDetailClientV2 } from './PopupDetailClientV2';
|
||||
export * from './types';
|
||||
191
src/components/settings/PopupManagement/popupDetailConfig.ts
Normal file
191
src/components/settings/PopupManagement/popupDetailConfig.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user