Files
sam-react-prod/src/components/templates/IntegratedDetailTemplate/index.tsx
유병철 f6551c7e8b feat(WEB): 전체 페이지 ?mode= URL 네비게이션 패턴 적용
- 등록(?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>
2026-01-25 12:27:43 +09:00

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';