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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
"{card.cardName}" 카드를 삭제하시겠습니까?
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/app/[locale]/(protected)/hr/card-management/loading.tsx
Normal file
58
src/app/[locale]/(protected)/hr/card-management/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/app/[locale]/(protected)/settings/accounts/loading.tsx
Normal file
46
src/app/[locale]/(protected)/settings/accounts/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
165
src/components/hr/CardManagement/cardConfig.ts
Normal file
165
src/components/hr/CardManagement/cardConfig.ts
Normal 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; // 기본 렌더링 사용
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
103
src/components/settings/AccountManagement/accountConfig.ts
Normal file
103
src/components/settings/AccountManagement/accountConfig.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
613
src/components/templates/IntegratedDetailTemplate/index.tsx
Normal file
613
src/components/templates/IntegratedDetailTemplate/index.tsx
Normal 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';
|
||||
219
src/components/templates/IntegratedDetailTemplate/types.ts
Normal file
219
src/components/templates/IntegratedDetailTemplate/types.ts
Normal 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;
|
||||
}
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 };
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user