- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
650 lines
20 KiB
TypeScript
650 lines
20 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* IntegratedDetailTemplate - 통합 상세/등록/수정 페이지 템플릿
|
|
*
|
|
* 등록(create), 상세(view), 수정(edit) 모드를 하나의 config로 통합
|
|
* - 기존 AccountDetail, CardForm 등의 패턴을 일반화
|
|
* - 필드 정의 기반 자동 렌더링
|
|
* - 커스텀 렌더러 지원 (renderView, renderForm, renderField)
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
import { toast } from 'sonner';
|
|
import { FieldInput } from './FieldInput';
|
|
import { DetailSection, DetailGrid, DetailField, DetailActions, DetailSectionSkeleton } from './components';
|
|
import type {
|
|
IntegratedDetailTemplateProps,
|
|
IntegratedDetailTemplateRef,
|
|
DetailMode,
|
|
FieldDefinition,
|
|
FieldOption,
|
|
ValidationRule,
|
|
} from './types';
|
|
|
|
// Inner component with forwardRef
|
|
function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
|
{
|
|
config,
|
|
mode: initialMode,
|
|
initialData,
|
|
itemId,
|
|
isLoading = false,
|
|
onSubmit,
|
|
onDelete,
|
|
onCancel,
|
|
onModeChange,
|
|
onEdit: onEditProp,
|
|
renderView,
|
|
renderForm,
|
|
renderField,
|
|
headerActions,
|
|
beforeContent,
|
|
afterContent,
|
|
buttonPosition = 'bottom',
|
|
}: IntegratedDetailTemplateProps<T>,
|
|
ref: React.ForwardedRef<IntegratedDetailTemplateRef>
|
|
) {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const locale = (params.locale as string) || 'ko';
|
|
|
|
// ===== 상태 =====
|
|
const [mode, setMode] = useState<DetailMode>(initialMode);
|
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [dynamicOptions, setDynamicOptions] = useState<Record<string, FieldOption[]>>({});
|
|
|
|
// ===== Ref 메서드 노출 (DevFill 등에서 사용) =====
|
|
useImperativeHandle(ref, () => ({
|
|
setFormData: (data: Record<string, unknown>) => {
|
|
setFormData(prev => ({ ...prev, ...data }));
|
|
},
|
|
getFormData: () => formData,
|
|
setFieldValue: (key: string, value: unknown) => {
|
|
setFormData(prev => ({ ...prev, [key]: value }));
|
|
},
|
|
validate: () => {
|
|
const newErrors: Record<string, string> = {};
|
|
config.fields.forEach((field) => {
|
|
const value = formData[field.key];
|
|
if (field.required && (value === null || value === undefined || value === '')) {
|
|
newErrors[field.key] = `${field.label}은(는) 필수입니다.`;
|
|
}
|
|
});
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
},
|
|
reset: () => {
|
|
if (initialData) {
|
|
const transformed = config.transformInitialData
|
|
? config.transformInitialData(initialData)
|
|
: (initialData as Record<string, unknown>);
|
|
setFormData(transformed);
|
|
} else {
|
|
const defaultData: Record<string, unknown> = {};
|
|
config.fields.forEach((field) => {
|
|
if (field.type === 'checkbox') {
|
|
defaultData[field.key] = false;
|
|
} else {
|
|
defaultData[field.key] = '';
|
|
}
|
|
});
|
|
setFormData(defaultData);
|
|
}
|
|
setErrors({});
|
|
},
|
|
}), [formData, config, initialData]);
|
|
|
|
// ===== 권한 계산 =====
|
|
const permissions = useMemo(() => {
|
|
const p = config.permissions || {};
|
|
return {
|
|
canEdit: typeof p.canEdit === 'function' ? p.canEdit() : p.canEdit ?? true,
|
|
canDelete: typeof p.canDelete === 'function' ? p.canDelete() : p.canDelete ?? true,
|
|
canCreate: typeof p.canCreate === 'function' ? p.canCreate() : p.canCreate ?? true,
|
|
};
|
|
}, [config.permissions]);
|
|
|
|
// ===== 모드 헬퍼 =====
|
|
const isViewMode = mode === 'view';
|
|
const isCreateMode = mode === 'create';
|
|
const isEditMode = mode === 'edit';
|
|
|
|
// ===== mode prop 변경 시 내부 state 동기화 =====
|
|
useEffect(() => {
|
|
setMode(initialMode);
|
|
}, [initialMode]);
|
|
|
|
// ===== 초기 데이터 설정 =====
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
const transformed = config.transformInitialData
|
|
? config.transformInitialData(initialData)
|
|
: (initialData as Record<string, unknown>);
|
|
setFormData(transformed);
|
|
} else {
|
|
// 기본값 설정
|
|
const defaultData: Record<string, unknown> = {};
|
|
config.fields.forEach((field) => {
|
|
if (field.type === 'checkbox') {
|
|
defaultData[field.key] = false;
|
|
} else {
|
|
defaultData[field.key] = '';
|
|
}
|
|
});
|
|
setFormData(defaultData);
|
|
}
|
|
}, [initialData, config.transformInitialData, config.fields]);
|
|
|
|
// ===== 동적 옵션 로드 =====
|
|
useEffect(() => {
|
|
const loadDynamicOptions = async () => {
|
|
const optionsToLoad = config.fields.filter((f) => f.fetchOptions);
|
|
const results: Record<string, FieldOption[]> = {};
|
|
|
|
await Promise.all(
|
|
optionsToLoad.map(async (field) => {
|
|
try {
|
|
const options = await field.fetchOptions!();
|
|
results[field.key] = options;
|
|
} catch (error) {
|
|
console.error(`Failed to load options for ${field.key}:`, error);
|
|
results[field.key] = [];
|
|
}
|
|
})
|
|
);
|
|
|
|
setDynamicOptions(results);
|
|
};
|
|
|
|
loadDynamicOptions();
|
|
}, [config.fields]);
|
|
|
|
// ===== 필드 변경 핸들러 =====
|
|
const handleChange = useCallback((key: string, value: unknown) => {
|
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
|
// 에러 클리어
|
|
if (errors[key]) {
|
|
setErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
}
|
|
}, [errors]);
|
|
|
|
// ===== 유효성 검사 =====
|
|
const validate = useCallback((): boolean => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
config.fields.forEach((field) => {
|
|
const value = formData[field.key];
|
|
|
|
// 필수 검사
|
|
if (field.required) {
|
|
if (value === null || value === undefined || value === '') {
|
|
newErrors[field.key] = `${field.label}은(는) 필수입니다.`;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 커스텀 유효성 검사
|
|
if (field.validation) {
|
|
for (const rule of field.validation) {
|
|
if (!validateRule(rule, value, formData)) {
|
|
newErrors[field.key] = rule.message;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
}, [config.fields, formData]);
|
|
|
|
// ===== 네비게이션 =====
|
|
const navigateToList = useCallback(() => {
|
|
router.push(`/${locale}${config.basePath}`);
|
|
}, [router, locale, config.basePath]);
|
|
|
|
// ===== 취소 핸들러 =====
|
|
const handleCancel = useCallback(() => {
|
|
if (onCancel) {
|
|
onCancel();
|
|
} else if (isCreateMode) {
|
|
navigateToList();
|
|
} else {
|
|
setMode('view');
|
|
// 원래 데이터로 복원
|
|
if (initialData) {
|
|
const transformed = config.transformInitialData
|
|
? config.transformInitialData(initialData)
|
|
: (initialData as Record<string, unknown>);
|
|
setFormData(transformed);
|
|
}
|
|
setErrors({});
|
|
}
|
|
}, [onCancel, isCreateMode, navigateToList, initialData, config.transformInitialData]);
|
|
|
|
// ===== 제출 핸들러 =====
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!validate()) {
|
|
toast.error('입력 정보를 확인해주세요.');
|
|
return;
|
|
}
|
|
|
|
if (!onSubmit) {
|
|
toast.error('저장 핸들러가 설정되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const dataToSubmit = config.transformSubmitData
|
|
? config.transformSubmitData(formData)
|
|
: formData;
|
|
|
|
const result = await onSubmit(dataToSubmit);
|
|
if (result?.success) {
|
|
toast.success(isCreateMode ? '등록되었습니다.' : '저장되었습니다.');
|
|
navigateToList();
|
|
} else {
|
|
toast.error(result?.error || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Submit error:', error);
|
|
toast.error('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [validate, onSubmit, config.transformSubmitData, formData, isCreateMode, navigateToList]);
|
|
|
|
// ===== 삭제 핸들러 =====
|
|
const handleDelete = useCallback(() => {
|
|
setShowDeleteDialog(true);
|
|
}, []);
|
|
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!onDelete || !itemId) {
|
|
toast.error('삭제 핸들러가 설정되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const result = await onDelete(itemId);
|
|
if (result?.success) {
|
|
toast.success('삭제되었습니다.');
|
|
navigateToList();
|
|
} else {
|
|
toast.error(result?.error || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
toast.error('삭제 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
setShowDeleteDialog(false);
|
|
}
|
|
}, [onDelete, itemId, navigateToList]);
|
|
|
|
// ===== 수정 모드 전환 =====
|
|
const handleEdit = useCallback(() => {
|
|
// 커스텀 onEdit이 제공되면 해당 핸들러 사용 (예: 페이지 이동)
|
|
if (onEditProp) {
|
|
onEditProp();
|
|
return;
|
|
}
|
|
// 기본 동작: 내부 모드 변경 + URL 변경
|
|
setMode('edit');
|
|
onModeChange?.('edit');
|
|
// URL에 ?mode=edit 추가
|
|
if (itemId) {
|
|
router.push(`/${locale}${config.basePath}/${itemId}?mode=edit`);
|
|
}
|
|
}, [onEditProp, onModeChange, router, locale, config.basePath, itemId]);
|
|
|
|
// ===== 액션 설정 =====
|
|
const actions = config.actions || {};
|
|
const deleteConfirm = actions.deleteConfirmMessage || {};
|
|
|
|
// ===== 버튼 위치 =====
|
|
const isTopButtons = buttonPosition === 'top';
|
|
|
|
// ===== 액션 버튼 렌더링 헬퍼 =====
|
|
const renderActionButtons = useCallback((additionalClass?: string) => {
|
|
if (isViewMode) {
|
|
return (
|
|
<DetailActions
|
|
mode="view"
|
|
permissions={permissions}
|
|
showButtons={{
|
|
back: actions.showBack !== false,
|
|
delete: actions.showDelete !== false && !!onDelete,
|
|
edit: actions.showEdit !== false,
|
|
}}
|
|
labels={{
|
|
back: actions.backLabel,
|
|
delete: actions.deleteLabel,
|
|
edit: actions.editLabel,
|
|
}}
|
|
onBack={navigateToList}
|
|
onDelete={handleDelete}
|
|
onEdit={handleEdit}
|
|
extraActions={headerActions}
|
|
className={additionalClass}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Form 모드 (edit/create)
|
|
return (
|
|
<DetailActions
|
|
mode={mode}
|
|
isSubmitting={isSubmitting}
|
|
permissions={permissions}
|
|
showButtons={{
|
|
back: actions.showBack !== false,
|
|
delete: actions.showDelete !== false && !!onDelete,
|
|
edit: actions.showEdit !== false,
|
|
save: actions.showSave !== false,
|
|
}}
|
|
labels={{
|
|
back: actions.backLabel,
|
|
cancel: actions.cancelLabel,
|
|
delete: actions.deleteLabel,
|
|
edit: actions.editLabel,
|
|
submit: actions.submitLabel,
|
|
}}
|
|
onBack={navigateToList}
|
|
onCancel={handleCancel}
|
|
onDelete={handleDelete}
|
|
onEdit={handleEdit}
|
|
onSubmit={handleSubmit}
|
|
extraActions={headerActions}
|
|
className={additionalClass}
|
|
/>
|
|
);
|
|
}, [
|
|
isViewMode, mode, isSubmitting, permissions, actions, headerActions,
|
|
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
|
|
]);
|
|
|
|
// ===== 필터링된 필드 =====
|
|
const visibleFields = useMemo(() => {
|
|
return config.fields.filter((field) => {
|
|
if (isViewMode && field.hideInView) return false;
|
|
if (!isViewMode && field.hideInForm) return false;
|
|
return true;
|
|
});
|
|
}, [config.fields, isViewMode]);
|
|
|
|
// ===== 그리드 컬럼 수 =====
|
|
const gridCols = config.gridColumns || 2;
|
|
|
|
// ===== 로딩 상태 =====
|
|
if (isLoading) {
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title={config.title}
|
|
description={config.description}
|
|
icon={config.icon}
|
|
actions={isTopButtons ? renderActionButtons() : undefined}
|
|
/>
|
|
<DetailSectionSkeleton cols={gridCols} fieldCount={6} />
|
|
{!isTopButtons && renderActionButtons('mt-6')}
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// ===== View 모드 - 커스텀 렌더러 =====
|
|
if (isViewMode && renderView && initialData) {
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title={config.title}
|
|
description={config.description}
|
|
icon={config.icon}
|
|
actions={isTopButtons ? renderActionButtons() : undefined}
|
|
/>
|
|
{beforeContent}
|
|
{renderView(initialData)}
|
|
{afterContent}
|
|
{/* 버튼 영역 - 하단 배치 시만 */}
|
|
{!isTopButtons && renderActionButtons('mt-6')}
|
|
<DeleteConfirmDialog
|
|
open={showDeleteDialog}
|
|
onOpenChange={setShowDeleteDialog}
|
|
onConfirm={handleConfirmDelete}
|
|
title={deleteConfirm.title}
|
|
description={deleteConfirm.description}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// ===== Form 모드 - 커스텀 렌더러 =====
|
|
// View 모드에서 renderView가 없으면 renderForm으로 폴백 (EmployeeForm 등 renderForm만 사용하는 컴포넌트 지원)
|
|
if (renderForm && (!isViewMode || !renderView)) {
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title={isCreateMode ? `${config.title} 등록` : isViewMode ? config.title : `${config.title} 수정`}
|
|
description={config.description}
|
|
icon={config.icon}
|
|
actions={isTopButtons ? renderActionButtons() : undefined}
|
|
/>
|
|
{beforeContent}
|
|
{renderForm({
|
|
formData,
|
|
onChange: handleChange,
|
|
mode,
|
|
errors,
|
|
})}
|
|
{afterContent}
|
|
{/* 버튼 영역 - 하단 배치 시만 */}
|
|
{!isTopButtons && renderActionButtons('mt-6')}
|
|
{/* View 모드에서 renderForm 폴백 시 삭제 다이얼로그 필요 */}
|
|
{isViewMode && (
|
|
<DeleteConfirmDialog
|
|
open={showDeleteDialog}
|
|
onOpenChange={setShowDeleteDialog}
|
|
onConfirm={handleConfirmDelete}
|
|
title={deleteConfirm.title}
|
|
description={deleteConfirm.description}
|
|
/>
|
|
)}
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// ===== 기본 렌더링 =====
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title={
|
|
isCreateMode
|
|
? `${config.title} 등록`
|
|
: isEditMode
|
|
? `${config.title} 수정`
|
|
: config.title
|
|
}
|
|
description={config.description}
|
|
icon={config.icon}
|
|
actions={isTopButtons ? renderActionButtons() : undefined}
|
|
/>
|
|
|
|
{beforeContent}
|
|
|
|
<div className="space-y-6">
|
|
{/* 섹션이 있으면 섹션별로, 없으면 단일 카드 */}
|
|
{config.sections && config.sections.length > 0 ? (
|
|
config.sections.map((section) => (
|
|
<DetailSection
|
|
key={section.id}
|
|
title={section.title}
|
|
description={section.description}
|
|
collapsible={section.collapsible}
|
|
defaultOpen={!section.defaultCollapsed}
|
|
>
|
|
<DetailGrid cols={gridCols}>
|
|
{section.fields.map((fieldKey) => {
|
|
const field = visibleFields.find((f) => f.key === fieldKey);
|
|
if (!field) return null;
|
|
return renderFieldItem(field);
|
|
})}
|
|
</DetailGrid>
|
|
</DetailSection>
|
|
))
|
|
) : (
|
|
<DetailSection title="기본 정보">
|
|
<DetailGrid cols={gridCols}>
|
|
{visibleFields.map((field) => renderFieldItem(field))}
|
|
</DetailGrid>
|
|
</DetailSection>
|
|
)}
|
|
|
|
{afterContent}
|
|
|
|
{/* 버튼 영역 - 하단 배치 시만 */}
|
|
{!isTopButtons && renderActionButtons()}
|
|
</div>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<DeleteConfirmDialog
|
|
open={showDeleteDialog}
|
|
onOpenChange={setShowDeleteDialog}
|
|
onConfirm={handleConfirmDelete}
|
|
title={deleteConfirm.title}
|
|
description={deleteConfirm.description}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
|
|
// ===== 필드 아이템 렌더링 헬퍼 =====
|
|
function renderFieldItem(field: FieldDefinition) {
|
|
// gridSpan을 colSpan으로 매핑 (1, 2, 3, 4만 허용)
|
|
const colSpan = (field.gridSpan || 1) as 1 | 2 | 3 | 4;
|
|
|
|
// 커스텀 필드 렌더러 체크
|
|
if (renderField) {
|
|
const customRender = renderField(field, {
|
|
value: formData[field.key],
|
|
onChange: (value: unknown) => handleChange(field.key, value),
|
|
mode,
|
|
disabled:
|
|
field.readonly ||
|
|
(typeof field.disabled === 'function' ? field.disabled(mode) : !!field.disabled),
|
|
error: errors[field.key],
|
|
});
|
|
if (customRender !== null) {
|
|
return (
|
|
<DetailField
|
|
key={field.key}
|
|
label={field.label}
|
|
required={field.required}
|
|
error={errors[field.key]}
|
|
description={field.helpText}
|
|
colSpan={colSpan}
|
|
htmlFor={field.key}
|
|
mode={mode}
|
|
>
|
|
{customRender}
|
|
</DetailField>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<DetailField
|
|
key={field.key}
|
|
label={field.label}
|
|
required={field.required}
|
|
error={errors[field.key]}
|
|
description={field.helpText}
|
|
colSpan={colSpan}
|
|
htmlFor={field.key}
|
|
mode={mode}
|
|
>
|
|
<FieldInput
|
|
field={field}
|
|
value={formData[field.key]}
|
|
onChange={(value) => handleChange(field.key, value)}
|
|
mode={mode}
|
|
error={errors[field.key]}
|
|
dynamicOptions={dynamicOptions[field.key]}
|
|
/>
|
|
</DetailField>
|
|
);
|
|
}
|
|
}
|
|
|
|
// forwardRef wrapper with generic support
|
|
export const IntegratedDetailTemplate = forwardRef(IntegratedDetailTemplateInner) as <
|
|
T extends Record<string, unknown>
|
|
>(
|
|
props: IntegratedDetailTemplateProps<T> & { ref?: React.ForwardedRef<IntegratedDetailTemplateRef> }
|
|
) => React.ReactElement;
|
|
|
|
// ===== 유효성 검사 헬퍼 =====
|
|
function validateRule(
|
|
rule: ValidationRule,
|
|
value: unknown,
|
|
formData: Record<string, unknown>
|
|
): boolean {
|
|
const strValue = value !== null && value !== undefined ? String(value) : '';
|
|
|
|
switch (rule.type) {
|
|
case 'required':
|
|
return strValue.trim().length > 0;
|
|
|
|
case 'minLength':
|
|
return strValue.length >= (rule.value as number);
|
|
|
|
case 'maxLength':
|
|
return strValue.length <= (rule.value as number);
|
|
|
|
case 'pattern':
|
|
return new RegExp(rule.value as string).test(strValue);
|
|
|
|
case 'custom':
|
|
return rule.validate ? rule.validate(value, formData) : true;
|
|
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Re-export types
|
|
export * from './types';
|
|
|
|
// Re-export FieldInput
|
|
export { FieldInput, type FieldInputProps } from './FieldInput';
|
|
|
|
// Re-export internal components
|
|
export {
|
|
DetailSection,
|
|
DetailGrid,
|
|
DetailField,
|
|
DetailActions,
|
|
DetailFieldSkeleton,
|
|
DetailGridSkeleton,
|
|
DetailSectionSkeleton,
|
|
type DetailSectionProps,
|
|
type DetailGridProps,
|
|
type DetailFieldProps,
|
|
type DetailActionsProps,
|
|
type DetailFieldSkeletonProps,
|
|
type DetailGridSkeletonProps,
|
|
type DetailSectionSkeletonProps,
|
|
} from './components';
|