feat(WEB): IntegratedDetailTemplate 통합 템플릿 구현 및 Phase 1 마이그레이션

- IntegratedDetailTemplate 컴포넌트 구현 (등록/상세/수정 통합)
- accounts (계좌관리) IntegratedDetailTemplate 마이그레이션
- card-management (카드관리) IntegratedDetailTemplate 마이그레이션
- Skeleton UI 컴포넌트 추가 및 loading.tsx 적용
- 기존 CardDetail.tsx, CardForm.tsx _legacy 폴더로 백업

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-17 15:29:51 +09:00
parent d2f5f3d0b5
commit 1a6cde2d36
19 changed files with 2132 additions and 299 deletions

View File

@@ -1,63 +1,22 @@
'use client';
/**
* 카드 수정 페이지 - 상세 페이지로 리다이렉트
*
* IntegratedDetailTemplate 통합으로 인해 [id]?mode=edit로 처리됨
*/
import { useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { CardForm } from '@/components/hr/CardManagement/CardForm';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { toast } from 'sonner';
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
import { getCard, updateCard } from '@/components/hr/CardManagement/actions';
export default function CardEditPage() {
const router = useRouter();
const params = useParams();
const [card, setCard] = useState<Card | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const loadCard = async () => {
if (!params.id) return;
// 상세 페이지의 edit 모드로 리다이렉트
router.replace(`/ko/hr/card-management/${params.id}?mode=edit`);
}, [router, params.id]);
setIsLoading(true);
const result = await getCard(params.id as string);
if (result.success && result.data) {
setCard(result.data);
} else {
toast.error(result.error || '카드 정보를 불러오는데 실패했습니다.');
router.push('/ko/hr/card-management');
}
setIsLoading(false);
};
loadCard();
}, [params.id, router]);
const handleSubmit = async (data: CardFormData) => {
if (!params.id) return;
setIsSaving(true);
const result = await updateCard(params.id as string, data);
if (result.success) {
toast.success('카드가 수정되었습니다.');
router.push(`/ko/hr/card-management/${params.id}`);
} else {
toast.error(result.error || '수정에 실패했습니다.');
}
setIsSaving(false);
};
if (isLoading || !card) {
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
}
return (
<CardForm
mode="edit"
card={card}
onSubmit={handleSubmit}
/>
);
}
return null;
}

View File

@@ -1,110 +1,88 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
/**
* 카드 상세/수정 페이지 - IntegratedDetailTemplate 적용
*/
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { toast } from 'sonner';
import type { Card } from '@/components/hr/CardManagement/types';
import { getCard, deleteCard } from '@/components/hr/CardManagement/actions';
getCard,
updateCard,
deleteCard,
} from '@/components/hr/CardManagement/actions';
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
export default function CardDetailPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const cardId = params.id as string;
const [card, setCard] = useState<Card | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// URL에서 mode 파라미터 확인 (?mode=edit)
const urlMode = searchParams.get('mode');
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
// 데이터 로드
useEffect(() => {
const loadCard = async () => {
if (!params.id) return;
async function loadCard() {
setIsLoading(true);
const result = await getCard(params.id as string);
if (result.success && result.data) {
setCard(result.data);
} else {
toast.error(result.error || '카드 정보를 불러오는데 실패했습니다.');
router.push('/ko/hr/card-management');
try {
const result = await getCard(cardId);
if (result.success && result.data) {
setCard(result.data);
} else {
setError(result.error || '카드를 찾을 수 없습니다.');
}
} catch (err) {
console.error('Failed to load card:', err);
setError('카드 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
setIsLoading(false);
};
}
loadCard();
}, [params.id, router]);
}, [cardId]);
const handleEdit = () => {
router.push(`/ko/hr/card-management/${params.id}/edit`);
// 수정 핸들러
const handleSubmit = async (data: Record<string, unknown>) => {
const result = await updateCard(cardId, data as Partial<CardFormData>);
return { success: result.success, error: result.error };
};
const handleDelete = () => {
setDeleteDialogOpen(true);
// 삭제 핸들러
const handleDelete = async () => {
const result = await deleteCard(cardId);
return { success: result.success, error: result.error };
};
const confirmDelete = async () => {
if (!params.id) return;
setIsDeleting(true);
const result = await deleteCard(params.id as string);
if (result.success) {
toast.success('카드가 삭제되었습니다.');
router.push('/ko/hr/card-management');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
if (isLoading || !card) {
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
// 에러 상태
if (error && !isLoading) {
return (
<div className="p-6">
<div className="text-center py-8 text-muted-foreground">
{error}
</div>
</div>
);
}
return (
<>
<CardDetail
card={card}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{card.cardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<IntegratedDetailTemplate
config={cardConfig}
mode={initialMode}
initialData={card || undefined}
itemId={cardId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
/>
);
}
}

View File

@@ -0,0 +1,58 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
/**
* 카드관리 페이지 로딩 UI (Skeleton)
*
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
*/
export default function CardManagementLoading() {
return (
<div className="p-3 md:p-6 space-y-6">
{/* 헤더 Skeleton */}
<div className="space-y-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-48" />
</div>
{/* 기본 정보 카드 Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
{/* 사용자 정보 카드 Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
{/* 버튼 영역 Skeleton */}
<div className="flex items-center justify-between">
<Skeleton className="h-10 w-24" />
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
</div>
</div>
</div>
);
}

View File

@@ -1,33 +1,25 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { CardForm } from '@/components/hr/CardManagement/CardForm';
import { toast } from 'sonner';
import type { CardFormData } from '@/components/hr/CardManagement/types';
/**
* 카드 등록 페이지 - IntegratedDetailTemplate 적용
*/
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
import { createCard } from '@/components/hr/CardManagement/actions';
import type { CardFormData } from '@/components/hr/CardManagement/types';
export default function CardNewPage() {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const handleSubmit = async (data: CardFormData) => {
setIsSaving(true);
const result = await createCard(data);
if (result.success) {
toast.success('카드가 등록되었습니다.');
router.push('/ko/hr/card-management');
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
setIsSaving(false);
export default function NewCardPage() {
const handleSubmit = async (data: Record<string, unknown>) => {
const result = await createCard(data as CardFormData);
return { success: result.success, error: result.error };
};
return (
<CardForm
<IntegratedDetailTemplate
config={cardConfig}
mode="create"
onSubmit={handleSubmit}
/>
);
}
}

View File

@@ -8,7 +8,10 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
* - React Suspense 자동 적용
* - 페이지 전환 시 즉각적인 피드백
* - 공통 레이아웃 스타일로 통일 (min-h-[calc(100vh-200px)])
*
* Note: 특정 경로에서 Skeleton UI를 사용하려면 해당 경로에
* 별도의 loading.tsx를 생성하세요. (예: settings/accounts/loading.tsx)
*/
export default function ProtectedLoading() {
return <PageLoadingSpinner />;
}
}

View File

@@ -1,149 +1,88 @@
'use client';
import { useParams } from 'next/navigation';
import { AccountDetail } from '@/components/settings/AccountManagement/AccountDetail';
import type { Account } from '@/components/settings/AccountManagement/types';
/**
* 계좌 상세/수정 페이지 - IntegratedDetailTemplate 적용
*/
// Mock 데이터 (API 연동 전 임시)
const mockAccounts: Account[] = [
{
id: 1,
bankCode: 'shinhan',
bankName: '신한은행',
accountNumber: '1234-1234-1234-1234',
accountName: '운영계좌 1',
accountHolder: '예금주1',
status: 'active',
isPrimary: true,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 2,
bankCode: 'kb',
bankName: 'KB국민은행',
accountNumber: '1234-1234-1234-1235',
accountName: '운영계좌 2',
accountHolder: '예금주2',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 3,
bankCode: 'woori',
bankName: '우리은행',
accountNumber: '1234-1234-1234-1236',
accountName: '운영계좌 3',
accountHolder: '예금주3',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 4,
bankCode: 'hana',
bankName: '하나은행',
accountNumber: '1234-1234-1234-1237',
accountName: '운영계좌 4',
accountHolder: '예금주4',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 5,
bankCode: 'nh',
bankName: 'NH농협은행',
accountNumber: '1234-1234-1234-1238',
accountName: '운영계좌 5',
accountHolder: '예금주5',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 6,
bankCode: 'ibk',
bankName: 'IBK기업은행',
accountNumber: '1234-1234-1234-1239',
accountName: '운영계좌 6',
accountHolder: '예금주6',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 7,
bankCode: 'shinhan',
bankName: '신한은행',
accountNumber: '1234-1234-1234-1240',
accountName: '운영계좌 7',
accountHolder: '예금주7',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 8,
bankCode: 'kb',
bankName: 'KB국민은행',
accountNumber: '1234-1234-1234-1241',
accountName: '운영계좌 8',
accountHolder: '예금주8',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 9,
bankCode: 'woori',
bankName: '우리은행',
accountNumber: '1234-1234-1234-1242',
accountName: '운영계좌 9',
accountHolder: '예금주9',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 10,
bankCode: 'hana',
bankName: '하나은행',
accountNumber: '1234-1234-1234-1243',
accountName: '운영계좌 10',
accountHolder: '예금주10',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
];
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
import {
getBankAccount,
updateBankAccount,
deleteBankAccount,
} from '@/components/settings/AccountManagement/actions';
import type { Account, AccountFormData } from '@/components/settings/AccountManagement/types';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
export default function AccountDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const accountId = Number(params.id);
// Mock: 계좌 조회
const account = mockAccounts.find(a => a.id === accountId);
const [account, setAccount] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
if (!account) {
// URL에서 mode 파라미터 확인 (?mode=edit)
const urlMode = searchParams.get('mode');
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
// 데이터 로드
useEffect(() => {
async function loadAccount() {
setIsLoading(true);
try {
const result = await getBankAccount(accountId);
if (result.success && result.data) {
setAccount(result.data);
} else {
setError(result.error || '계좌를 찾을 수 없습니다.');
}
} catch (err) {
console.error('Failed to load account:', err);
setError('계좌 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}
loadAccount();
}, [accountId]);
// 수정 핸들러
const handleSubmit = async (data: Record<string, unknown>) => {
const result = await updateBankAccount(accountId, data as Partial<AccountFormData>);
return { success: result.success, error: result.error };
};
// 삭제 핸들러
const handleDelete = async () => {
const result = await deleteBankAccount(accountId);
return { success: result.success, error: result.error };
};
// 에러 상태
if (error && !isLoading) {
return (
<div className="p-6">
<div className="text-center py-8 text-muted-foreground">
.
{error}
</div>
</div>
);
}
return <AccountDetail account={account} mode="view" />;
}
return (
<IntegratedDetailTemplate
config={accountConfig}
mode={initialMode}
initialData={account || undefined}
itemId={accountId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
/>
);
}

View File

@@ -0,0 +1,46 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
/**
* 계좌관리 페이지 로딩 UI (Skeleton)
*
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
* Note: Server Component이므로 lucide 아이콘 직접 사용 불가
*/
export default function AccountsLoading() {
return (
<div className="p-3 md:p-6 space-y-6">
{/* 헤더 Skeleton */}
<div className="space-y-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-48" />
</div>
{/* 카드 Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
{/* 버튼 영역 Skeleton */}
<div className="flex items-center justify-between">
<Skeleton className="h-10 w-24" />
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,25 @@
'use client';
import { AccountDetail } from '@/components/settings/AccountManagement/AccountDetail';
/**
* 계좌 등록 페이지 - IntegratedDetailTemplate 적용
*/
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
export default function NewAccountPage() {
return <AccountDetail mode="create" />;
}
const handleSubmit = async (data: Record<string, unknown>) => {
const result = await createBankAccount(data as AccountFormData);
return { success: result.success, error: result.error };
};
return (
<IntegratedDetailTemplate
config={accountConfig}
mode="create"
onSubmit={handleSubmit}
/>
);
}

View File

@@ -0,0 +1,60 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
/**
* 권한관리 페이지 로딩 UI (Skeleton)
*
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
*/
export default function PermissionsLoading() {
return (
<div className="p-3 md:p-6 space-y-6">
{/* 헤더 Skeleton */}
<div className="space-y-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-48" />
</div>
{/* 검색/필터 영역 Skeleton */}
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-32" />
</div>
{/* 테이블 Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent>
{/* 테이블 헤더 */}
<div className="flex items-center gap-4 border-b pb-4 mb-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
{/* 테이블 행들 */}
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4 py-3 border-b last:border-b-0">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-20" />
</div>
))}
</CardContent>
</Card>
{/* 페이지네이션 Skeleton */}
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
/**
* Card Management - IntegratedDetailTemplate Config
*
* 카드관리 등록/상세/수정 페이지 설정
*/
import { CreditCard } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate';
import type { Card, CardFormData, CardStatus, CardCompany } from './types';
import {
CARD_COMPANIES,
CARD_STATUS_LABELS,
getCardCompanyLabel,
} from './types';
import { getActiveEmployees } from './actions';
// 상태 옵션
const CARD_STATUS_OPTIONS = [
{ value: 'active', label: CARD_STATUS_LABELS.active },
{ value: 'suspended', label: CARD_STATUS_LABELS.suspended },
];
export const cardConfig: DetailConfig<Card> = {
title: '카드',
description: '카드 정보를 관리합니다',
icon: CreditCard,
basePath: '/hr/card-management',
// 그리드 2열
gridColumns: 2,
// 섹션 정의
sections: [
{
id: 'basic',
title: '기본 정보',
fields: ['cardCompany', 'cardNumber', 'expiryDate', 'pinPrefix', 'cardName', 'status'],
},
{
id: 'user',
title: '사용자 정보',
fields: ['userId'],
},
],
// 필드 정의
fields: [
{
key: 'cardCompany',
label: '카드사',
type: 'select',
required: true,
options: CARD_COMPANIES.map(c => ({ value: c.value, label: c.label })),
placeholder: '카드사를 선택하세요',
},
{
key: 'cardNumber',
label: '카드번호',
type: 'text',
required: true,
placeholder: '1234-1234-1234-1234',
helpText: '16자리 카드번호를 입력하세요',
},
{
key: 'expiryDate',
label: '유효기간',
type: 'text',
required: true,
placeholder: 'MMYY',
helpText: '월/년 4자리 (예: 1225)',
},
{
key: 'pinPrefix',
label: '카드 비밀번호 앞 2자리',
type: 'password',
placeholder: '**',
},
{
key: 'cardName',
label: '카드명',
type: 'text',
placeholder: '카드명을 입력해주세요',
},
{
key: 'status',
label: '상태',
type: 'select',
required: true,
options: CARD_STATUS_OPTIONS,
placeholder: '상태 선택',
},
{
key: 'userId',
label: '부서 / 이름 / 직책',
type: 'select',
placeholder: '선택해서 해당 카드의 사용자로 설정',
gridSpan: 2,
// 동적 옵션 로드
fetchOptions: async () => {
const result = await getActiveEmployees();
if (result.success && result.data) {
return result.data.map(emp => ({ value: emp.id, label: emp.label }));
}
return [];
},
},
],
// 액션 설정
actions: {
showDelete: true,
showEdit: true,
showBack: true,
deleteConfirmMessage: {
title: '카드 삭제',
description: '카드를 정말 삭제하시겠습니까?\n삭제된 카드 정보는 복구할 수 없습니다.',
},
},
// 초기 데이터 변환 (API 응답 → formData)
transformInitialData: (card: Card): Record<string, unknown> => ({
cardCompany: card.cardCompany || '',
cardNumber: card.cardNumber || '',
cardName: card.cardName || '',
expiryDate: card.expiryDate || '',
pinPrefix: '', // 비밀번호는 항상 빈 값
status: card.status || 'active',
userId: card.user?.id || '',
}),
// 제출 데이터 변환 (formData → API 요청)
transformSubmitData: (formData: Record<string, unknown>): Partial<CardFormData> => ({
cardCompany: formData.cardCompany as CardCompany,
cardNumber: formData.cardNumber as string,
cardName: formData.cardName as string,
expiryDate: formData.expiryDate as string,
pinPrefix: formData.pinPrefix as string,
status: formData.status as CardStatus,
userId: formData.userId as string,
}),
// View 모드 값 포맷터
formatViewValue: (key: string, value: unknown, data: Record<string, unknown>) => {
switch (key) {
case 'cardCompany':
return getCardCompanyLabel(value as CardCompany);
case 'expiryDate':
// MMYY → MM/YY
const date = value as string;
if (date && date.length === 4) {
return `${date.slice(0, 2)}/${date.slice(2)}`;
}
return date || '-';
case 'userId':
// 사용자 정보 조합 표시
const userData = data as Card;
if (userData.user) {
return `${userData.user.departmentName} / ${userData.user.employeeName} / ${userData.user.positionName}`;
}
return '미지정';
default:
return undefined; // 기본 렌더링 사용
}
},
};

View File

@@ -0,0 +1,369 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Landmark, Save, Trash2, X, Edit, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { Account, AccountFormData, AccountStatus } from './types';
import {
BANK_OPTIONS,
BANK_LABELS,
ACCOUNT_STATUS_OPTIONS,
ACCOUNT_STATUS_LABELS,
ACCOUNT_STATUS_COLORS,
} from './types';
import { createBankAccount, updateBankAccount, deleteBankAccount } from './actions';
interface AccountDetailProps {
account?: Account;
mode: 'create' | 'view' | 'edit';
}
export function AccountDetail({ account, mode: initialMode }: AccountDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState(initialMode);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// URL에서 mode 파라미터 확인
useEffect(() => {
const urlMode = searchParams.get('mode');
if (urlMode === 'edit' && account) {
setMode('edit');
}
}, [searchParams, account]);
// 폼 상태
const [formData, setFormData] = useState<AccountFormData>({
bankCode: account?.bankCode || '',
bankName: account?.bankName || '',
accountNumber: account?.accountNumber || '',
accountName: account?.accountName || '',
accountHolder: account?.accountHolder || '',
accountPassword: '',
status: account?.status || 'active',
});
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const isEditMode = mode === 'edit';
const handleChange = (field: keyof AccountFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleBack = () => {
router.push('/ko/settings/accounts');
};
const handleSubmit = async () => {
const dataToSend = {
...formData,
bankName: BANK_LABELS[formData.bankCode] || formData.bankCode,
};
if (isCreateMode) {
const result = await createBankAccount(dataToSend);
if (result.success) {
toast.success('계좌가 등록되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 등록에 실패했습니다.');
}
} else {
if (!account?.id) return;
const result = await updateBankAccount(account.id, dataToSend);
if (result.success) {
toast.success('계좌가 수정되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 수정에 실패했습니다.');
}
}
};
const handleDelete = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = async () => {
if (!account?.id) return;
const result = await deleteBankAccount(account.id);
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
}
};
const handleCancel = () => {
if (isCreateMode) {
router.push('/ko/settings/accounts');
} else {
setMode('view');
// 원래 데이터로 복원
if (account) {
setFormData({
bankCode: account.bankCode, bankName: account.bankName,
accountNumber: account.accountNumber,
accountName: account.accountName,
accountHolder: account.accountHolder,
accountPassword: '',
status: account.status,
});
}
}
};
const handleEdit = () => {
setMode('edit');
};
// 뷰 모드 렌더링
if (isViewMode) {
return (
<PageLayout>
<PageHeader
title="계좌 상세"
description="계좌 정보를 관리합니다"
icon={Landmark}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
{ACCOUNT_STATUS_LABELS[formData.status]}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{BANK_LABELS[formData.bankCode] || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1 font-mono">{formData.accountNumber || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formData.accountHolder || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> </dt>
<dd className="text-sm mt-1">****</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formData.accountName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
{ACCOUNT_STATUS_LABELS[formData.status]}
</Badge>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}
// 생성/수정 모드 렌더링
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '계좌 등록' : '계좌 수정'}
description="계좌 정보를 관리합니다"
icon={Landmark}
/>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 은행 & 계좌번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bankCode"></Label>
<Select
value={formData.bankCode}
onValueChange={(value) => handleChange('bankCode', value)}
>
<SelectTrigger>
<SelectValue placeholder="은행 선택" />
</SelectTrigger>
<SelectContent>
{BANK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="accountNumber"></Label>
<Input
id="accountNumber"
value={formData.accountNumber}
onChange={(e) => handleChange('accountNumber', e.target.value)}
placeholder="1234-1234-1234-1234"
disabled={isEditMode} // 수정 시 계좌번호는 변경 불가
/>
</div>
</div>
{/* 예금주 & 계좌 비밀번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="accountHolder"></Label>
<Input
id="accountHolder"
value={formData.accountHolder}
onChange={(e) => handleChange('accountHolder', e.target.value)}
placeholder="예금주명"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accountPassword">
( )
</Label>
<Input
id="accountPassword"
type="password"
value={formData.accountPassword}
onChange={(e) => handleChange('accountPassword', e.target.value)}
placeholder="****"
/>
</div>
</div>
{/* 계좌명 & 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="accountName"></Label>
<Input
id="accountName"
value={formData.accountName}
onChange={(e) => handleChange('accountName', e.target.value)}
placeholder="계좌명을 입력해주세요"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) => handleChange('status', value as AccountStatus)}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleCancel}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSubmit}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? '등록' : '저장'}
</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,103 @@
/**
* Account Management - IntegratedDetailTemplate Config
*
* 계좌관리 등록/상세/수정 페이지 설정
*/
import { Landmark } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate';
import type { Account, AccountFormData, AccountStatus } from './types';
import { BANK_OPTIONS, ACCOUNT_STATUS_OPTIONS, BANK_LABELS } from './types';
export const accountConfig: DetailConfig<Account> = {
title: '계좌',
description: '계좌 정보를 관리합니다',
icon: Landmark,
basePath: '/settings/accounts',
// 그리드 2열
gridColumns: 2,
// 필드 정의
fields: [
{
key: 'bankCode',
label: '은행',
type: 'select',
required: true,
options: BANK_OPTIONS,
placeholder: '은행 선택',
},
{
key: 'accountNumber',
label: '계좌번호',
type: 'text',
required: true,
placeholder: '1234-1234-1234-1234',
// 수정 모드에서 비활성화 (계좌번호 변경 불가)
disabled: (mode) => mode === 'edit',
},
{
key: 'accountHolder',
label: '예금주',
type: 'text',
required: true,
placeholder: '예금주명',
},
{
key: 'accountPassword',
label: '계좌 비밀번호 (빠른 조회 서비스)',
type: 'password',
placeholder: '****',
helpText: '빠른 조회 서비스 이용 시 필요합니다',
// view 모드에서는 **** 로 표시됨 (FieldRenderer 기본 동작)
},
{
key: 'accountName',
label: '계좌명',
type: 'text',
placeholder: '계좌명을 입력해주세요',
},
{
key: 'status',
label: '상태',
type: 'select',
required: true,
options: ACCOUNT_STATUS_OPTIONS,
placeholder: '상태 선택',
},
],
// 액션 설정
actions: {
showDelete: true,
showEdit: true,
showBack: true,
deleteConfirmMessage: {
title: '계좌 삭제',
description: '계좌를 정말 삭제하시겠습니까?\n삭제된 계좌의 과거 사용 내역은 보존됩니다.',
},
},
// 초기 데이터 변환 (API 응답 → formData)
transformInitialData: (account: Account): Record<string, unknown> => ({
bankCode: account.bankCode || '',
bankName: account.bankName || '',
accountNumber: account.accountNumber || '',
accountName: account.accountName || '',
accountHolder: account.accountHolder || '',
accountPassword: '', // 비밀번호는 항상 빈 값
status: account.status || 'active',
}),
// 제출 데이터 변환 (formData → API 요청)
transformSubmitData: (formData: Record<string, unknown>): Partial<AccountFormData> => ({
bankCode: formData.bankCode as string,
bankName: BANK_LABELS[formData.bankCode as string] || (formData.bankCode as string),
accountNumber: formData.accountNumber as string,
accountName: formData.accountName as string,
accountHolder: formData.accountHolder as string,
accountPassword: formData.accountPassword as string,
status: formData.status as AccountStatus,
}),
};

View File

@@ -0,0 +1,296 @@
'use client';
/**
* FieldRenderer - 필드 타입별 렌더링 컴포넌트
*/
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { FieldDefinition, DetailMode, FieldOption } from './types';
import type { ReactNode } from 'react';
interface FieldRendererProps {
field: FieldDefinition;
value: unknown;
onChange: (value: unknown) => void;
mode: DetailMode;
error?: string;
dynamicOptions?: FieldOption[];
}
export function FieldRenderer({
field,
value,
onChange,
mode,
error,
dynamicOptions,
}: FieldRendererProps) {
const isViewMode = mode === 'view';
const isDisabled =
field.readonly ||
(typeof field.disabled === 'function'
? field.disabled(mode)
: field.disabled);
// 옵션 (동적 로드된 옵션 우선)
const options = dynamicOptions || field.options || [];
// View 모드: 값만 표시
if (isViewMode) {
return (
<div className="space-y-1">
<Label className="text-sm font-medium text-muted-foreground">
{field.label}
</Label>
<div className="text-sm mt-1">
{renderViewValue(field, value, options)}
</div>
</div>
);
}
// Form 모드: 입력 필드
return (
<div className="space-y-2">
<Label
htmlFor={field.key}
className={cn(field.required && "after:content-['*'] after:ml-0.5 after:text-red-500")}
>
{field.label}
</Label>
{renderFormField(field, value, onChange, isDisabled, options, error)}
{field.helpText && (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
// View 모드 값 렌더링
function renderViewValue(
field: FieldDefinition,
value: unknown,
options: FieldOption[]
): ReactNode {
// 커스텀 포맷터가 있으면 사용
if (field.formatValue) {
return field.formatValue(value);
}
// 값이 없으면 '-' 표시
if (value === null || value === undefined || value === '') {
return '-';
}
switch (field.type) {
case 'password':
return '****';
case 'select':
case 'radio': {
const option = options.find((opt) => opt.value === value);
return option?.label || String(value);
}
case 'checkbox':
return value ? '예' : '아니오';
case 'date':
if (typeof value === 'string') {
try {
return new Date(value).toLocaleDateString('ko-KR');
} catch {
return value;
}
}
return String(value);
case 'textarea':
return (
<div className="whitespace-pre-wrap">{String(value)}</div>
);
default:
return String(value);
}
}
// Form 모드 필드 렌더링
function renderFormField(
field: FieldDefinition,
value: unknown,
onChange: (value: unknown) => void,
disabled: boolean,
options: FieldOption[],
error?: string
): ReactNode {
const stringValue = value !== null && value !== undefined ? String(value) : '';
switch (field.type) {
case 'text':
case 'email':
case 'tel':
return (
<Input
id={field.key}
type={field.type}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
className={cn(error && 'border-red-500')}
/>
);
case 'number':
return (
<Input
id={field.key}
type="number"
value={stringValue}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
placeholder={field.placeholder}
disabled={disabled}
className={cn(error && 'border-red-500')}
/>
);
case 'password':
return (
<Input
id={field.key}
type="password"
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || '****'}
disabled={disabled}
className={cn(error && 'border-red-500')}
/>
);
case 'textarea':
return (
<Textarea
id={field.key}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
className={cn(error && 'border-red-500')}
rows={4}
/>
);
case 'select':
return (
<Select
key={`${field.key}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={cn(error && 'border-red-500')}>
<SelectValue placeholder={field.placeholder || '선택하세요'} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case 'radio':
return (
<RadioGroup
value={stringValue}
onValueChange={onChange}
disabled={disabled}
className="flex flex-wrap gap-4"
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${field.key}-${option.value}`} />
<Label
htmlFor={`${field.key}-${option.value}`}
className="font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
id={field.key}
checked={!!value}
onCheckedChange={onChange}
disabled={disabled}
/>
<Label
htmlFor={field.key}
className="font-normal cursor-pointer"
>
{field.placeholder || '동의'}
</Label>
</div>
);
case 'date':
return (
<Input
id={field.key}
type="date"
value={stringValue}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(error && 'border-red-500')}
/>
);
case 'custom':
if (field.renderField) {
return field.renderField({
value,
onChange,
mode: 'edit',
disabled,
});
}
return <div className="text-muted-foreground"> </div>;
default:
return (
<Input
id={field.key}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
/>
);
}
}

View File

@@ -0,0 +1,613 @@
'use client';
/**
* IntegratedDetailTemplate - 통합 상세/등록/수정 페이지 템플릿
*
* 등록(create), 상세(view), 수정(edit) 모드를 하나의 config로 통합
* - 기존 AccountDetail, CardForm 등의 패턴을 일반화
* - 필드 정의 기반 자동 렌더링
* - 커스텀 렌더러 지원 (renderView, renderForm, renderField)
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { FieldRenderer } from './FieldRenderer';
import type {
IntegratedDetailTemplateProps,
DetailMode,
FieldDefinition,
FieldOption,
ValidationRule,
} from './types';
export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
config,
mode: initialMode,
initialData,
itemId,
isLoading = false,
onSubmit,
onDelete,
onCancel,
onModeChange,
renderView,
renderForm,
renderField,
headerActions,
beforeContent,
afterContent,
}: IntegratedDetailTemplateProps<T>) {
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[]>>({});
// ===== 권한 계산 =====
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';
// ===== 초기 데이터 설정 =====
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(() => {
setMode('edit');
onModeChange?.('edit');
}, [onModeChange]);
// ===== 액션 설정 =====
const actions = config.actions || {};
const deleteConfirm = actions.deleteConfirmMessage || {};
// ===== 필터링된 필드 =====
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;
const gridClass = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}[gridCols];
// ===== 로딩 상태 =====
if (isLoading) {
return (
<PageLayout>
<PageHeader
title={config.title}
description={config.description}
icon={config.icon}
/>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
<Skeleton className="h-8 w-1/3" />
<div className={cn('grid gap-6', gridClass)}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
</PageLayout>
);
}
// ===== View 모드 - 커스텀 렌더러 =====
if (isViewMode && renderView && initialData) {
return (
<PageLayout>
<PageHeader
title={config.title}
description={config.description}
icon={config.icon}
/>
{beforeContent}
{renderView(initialData)}
{afterContent}
{/* 버튼 영역 */}
<div className="flex items-center justify-between mt-6">
<Button variant="outline" onClick={navigateToList}>
<ArrowLeft className="w-4 h-4 mr-2" />
{actions.backLabel || '목록으로'}
</Button>
<div className="flex items-center gap-2">
{headerActions}
{permissions.canDelete && onDelete && (actions.showDelete !== false) && (
<Button
variant="outline"
onClick={handleDelete}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
{actions.deleteLabel || '삭제'}
</Button>
)}
{permissions.canEdit && (actions.showEdit !== false) && (
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
{actions.editLabel || '수정'}
</Button>
)}
</div>
</div>
<DeleteDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title={deleteConfirm.title}
description={deleteConfirm.description}
/>
</PageLayout>
);
}
// ===== Form 모드 - 커스텀 렌더러 =====
if (!isViewMode && renderForm) {
return (
<PageLayout>
<PageHeader
title={isCreateMode ? `${config.title} 등록` : `${config.title} 수정`}
description={config.description}
icon={config.icon}
/>
{beforeContent}
{renderForm({
formData,
onChange: handleChange,
mode,
errors,
})}
{afterContent}
{/* 버튼 영역 */}
<div className="flex items-center justify-between mt-6">
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
{actions.cancelLabel || '취소'}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? (actions.submitLabel || '등록') : (actions.submitLabel || '저장')}
</Button>
</div>
</PageLayout>
);
}
// ===== 기본 렌더링 =====
return (
<PageLayout>
<PageHeader
title={
isCreateMode
? `${config.title} 등록`
: isEditMode
? `${config.title} 수정`
: config.title
}
description={config.description}
icon={config.icon}
/>
{beforeContent}
<div className="space-y-6">
{/* 섹션이 있으면 섹션별로, 없으면 단일 카드 */}
{config.sections && config.sections.length > 0 ? (
config.sections.map((section) => (
<Card key={section.id}>
<CardHeader>
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
</CardHeader>
<CardContent>
<div className={cn('grid gap-6', gridClass)}>
{section.fields.map((fieldKey) => {
const field = visibleFields.find((f) => f.key === fieldKey);
if (!field) return null;
return renderFieldItem(field);
})}
</div>
</CardContent>
</Card>
))
) : (
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className={cn('grid gap-6', gridClass)}>
{visibleFields.map((field) => renderFieldItem(field))}
</div>
</CardContent>
</Card>
)}
{afterContent}
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
{isViewMode ? (
<>
<Button variant="outline" onClick={navigateToList}>
<ArrowLeft className="w-4 h-4 mr-2" />
{actions.backLabel || '목록으로'}
</Button>
<div className="flex items-center gap-2">
{headerActions}
{permissions.canDelete && onDelete && (actions.showDelete !== false) && (
<Button
variant="outline"
onClick={handleDelete}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
{actions.deleteLabel || '삭제'}
</Button>
)}
{permissions.canEdit && (actions.showEdit !== false) && (
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
{actions.editLabel || '수정'}
</Button>
)}
</div>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
{actions.cancelLabel || '취소'}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? (actions.submitLabel || '등록') : (actions.submitLabel || '저장')}
</Button>
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title={deleteConfirm.title}
description={deleteConfirm.description}
/>
</PageLayout>
);
// ===== 필드 아이템 렌더링 헬퍼 =====
function renderFieldItem(field: FieldDefinition) {
const spanClass = {
1: '',
2: 'md:col-span-2',
3: 'md:col-span-2 lg:col-span-3',
}[field.gridSpan || 1];
// 커스텀 필드 렌더러 체크
if (renderField) {
const customRender = renderField(field, {
value: formData[field.key],
onChange: (value) => 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 (
<div key={field.key} className={spanClass}>
{customRender}
</div>
);
}
}
return (
<div key={field.key} className={spanClass}>
<FieldRenderer
field={field}
value={formData[field.key]}
onChange={(value) => handleChange(field.key, value)}
mode={mode}
error={errors[field.key]}
dynamicOptions={dynamicOptions[field.key]}
/>
</div>
);
}
}
// ===== 삭제 확인 다이얼로그 =====
function DeleteDialog({
open,
onOpenChange,
onConfirm,
title,
description,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title?: string;
description?: string;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title || '삭제 확인'}</AlertDialogTitle>
<AlertDialogDescription>
{description || '정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ===== 유효성 검사 헬퍼 =====
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';

View File

@@ -0,0 +1,219 @@
/**
* IntegratedDetailTemplate Types
*
* 등록/상세/수정 페이지를 위한 통합 템플릿 타입 정의
*/
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
// ===== 모드 =====
export type DetailMode = 'create' | 'view' | 'edit';
// ===== 필드 타입 =====
export type FieldType =
| 'text'
| 'number'
| 'select'
| 'date'
| 'textarea'
| 'password'
| 'email'
| 'tel'
| 'radio'
| 'checkbox'
| 'dateRange'
| 'richtext'
| 'file'
| 'custom';
// ===== 옵션 (select, radio용) =====
export interface FieldOption {
value: string;
label: string;
disabled?: boolean;
}
// ===== 유효성 검사 =====
export interface ValidationRule {
type: 'required' | 'minLength' | 'maxLength' | 'pattern' | 'custom';
value?: number | string | RegExp;
message: string;
validate?: (value: unknown, formData: Record<string, unknown>) => boolean;
}
// ===== 필드 정의 =====
export interface FieldDefinition {
/** 필드 키 (formData의 키) */
key: string;
/** 라벨 */
label: string;
/** 필드 타입 */
type: FieldType;
/** 필수 여부 */
required?: boolean;
/** 비활성화 여부 (모드별로 다르게 설정 가능) */
disabled?: boolean | ((mode: DetailMode) => boolean);
/** 읽기 전용 */
readonly?: boolean;
/** select, radio 옵션 */
options?: FieldOption[];
/** 동적 옵션 로드 함수 */
fetchOptions?: () => Promise<FieldOption[]>;
/** placeholder */
placeholder?: string;
/** 유효성 검사 규칙 */
validation?: ValidationRule[];
/** 그리드 span (1, 2, 3) - 기본값 1 */
gridSpan?: 1 | 2 | 3;
/** view 모드에서 숨김 */
hideInView?: boolean;
/** form 모드에서 숨김 */
hideInForm?: boolean;
/** 값 포맷터 (view 모드용) */
formatValue?: (value: unknown) => string | ReactNode;
/** 커스텀 렌더러 */
renderField?: (props: {
value: unknown;
onChange: (value: unknown) => void;
mode: DetailMode;
disabled: boolean;
}) => ReactNode;
/** 도움말 텍스트 */
helpText?: string;
/** 부가 정보 (Badge 등) */
suffix?: ReactNode | ((value: unknown) => ReactNode);
}
// ===== 섹션 정의 =====
export interface SectionDefinition {
/** 섹션 ID */
id: string;
/** 섹션 제목 */
title: string;
/** 섹션 설명 */
description?: string;
/** 섹션에 포함될 필드 키 목록 */
fields: string[];
/** 접을 수 있는 섹션 여부 */
collapsible?: boolean;
/** 기본 접힌 상태 */
defaultCollapsed?: boolean;
}
// ===== 액션 설정 =====
export interface ActionConfig {
/** 저장 버튼 텍스트 */
submitLabel?: string;
/** 취소 버튼 텍스트 */
cancelLabel?: string;
/** 삭제 버튼 표시 */
showDelete?: boolean;
/** 삭제 버튼 텍스트 */
deleteLabel?: string;
/** 수정 버튼 표시 (view 모드) */
showEdit?: boolean;
/** 수정 버튼 텍스트 */
editLabel?: string;
/** 목록 버튼 표시 */
showBack?: boolean;
/** 목록 버튼 텍스트 */
backLabel?: string;
/** 삭제 확인 메시지 */
deleteConfirmMessage?: {
title?: string;
description?: string;
};
}
// ===== 권한 설정 =====
export interface PermissionConfig {
/** 수정 권한 */
canEdit?: boolean | (() => boolean);
/** 삭제 권한 */
canDelete?: boolean | (() => boolean);
/** 생성 권한 */
canCreate?: boolean | (() => boolean);
}
// ===== 상세 페이지 설정 =====
export interface DetailConfig<T = Record<string, unknown>> {
/** 페이지 제목 */
title: string;
/** 페이지 설명 */
description?: string;
/** 아이콘 */
icon?: LucideIcon;
/** 기본 경로 (목록으로 돌아갈 때 사용) */
basePath: string;
/** 필드 정의 */
fields: FieldDefinition[];
/** 섹션 정의 (필드 그룹핑) */
sections?: SectionDefinition[];
/** 그리드 컬럼 수 (기본값: 2) */
gridColumns?: 1 | 2 | 3;
/** 액션 설정 */
actions?: ActionConfig;
/** 권한 설정 */
permissions?: PermissionConfig;
/** 초기값 변환 (API 응답 → formData) */
transformInitialData?: (data: T) => Record<string, unknown>;
/** 제출 데이터 변환 (formData → API 요청) */
transformSubmitData?: (formData: Record<string, unknown>) => Partial<T>;
}
// ===== 컴포넌트 Props =====
export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
/** 설정 */
config: DetailConfig<T>;
/** 모드 */
mode: DetailMode;
/** 초기 데이터 (view/edit 모드) */
initialData?: T;
/** 아이템 ID (view/edit 모드) */
itemId?: string | number;
/** 로딩 상태 */
isLoading?: boolean;
/** 저장 핸들러 */
onSubmit?: (data: Record<string, unknown>) => Promise<{ success: boolean; error?: string }>;
/** 삭제 핸들러 */
onDelete?: (id: string | number) => Promise<{ success: boolean; error?: string }>;
/** 취소 핸들러 (기본: 목록으로 이동) */
onCancel?: () => void;
/** 모드 변경 핸들러 (view → edit) */
onModeChange?: (mode: DetailMode) => void;
/** 커스텀 상세 화면 렌더러 */
renderView?: (data: T) => ReactNode;
/** 커스텀 폼 렌더러 */
renderForm?: (props: {
formData: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
mode: DetailMode;
errors: Record<string, string>;
}) => ReactNode;
/** 커스텀 필드 렌더러 (특정 필드만 커스텀) */
renderField?: (
field: FieldDefinition,
props: {
value: unknown;
onChange: (value: unknown) => void;
mode: DetailMode;
disabled: boolean;
error?: string;
}
) => ReactNode | null;
/** 헤더 우측 추가 액션 */
headerActions?: ReactNode;
/** 폼 앞에 추가 콘텐츠 */
beforeContent?: ReactNode;
/** 폼 뒤에 추가 콘텐츠 */
afterContent?: ReactNode;
}
// ===== API 응답 타입 =====
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
message?: string;
}

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -302,7 +302,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
}, [pathname, menuItems, setActiveMenu]);
const handleMenuClick = (menuId: string, path: string) => {
setActiveMenu(menuId);
// 네비게이션 우선 실행 - setActiveMenu는 pathname 기반 useEffect가 처리
router.push(path);
};