From 1a6cde2d36398660eb1f9419cd94c9cde644b3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Sat, 17 Jan 2026 15:29:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20IntegratedDetailTemplate=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20Phase=201=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntegratedDetailTemplate 컴포넌트 구현 (등록/상세/수정 통합) - accounts (계좌관리) IntegratedDetailTemplate 마이그레이션 - card-management (카드관리) IntegratedDetailTemplate 마이그레이션 - Skeleton UI 컴포넌트 추가 및 loading.tsx 적용 - 기존 CardDetail.tsx, CardForm.tsx _legacy 폴더로 백업 Co-Authored-By: Claude Opus 4.5 --- .../hr/card-management/[id]/edit/page.tsx | 65 +- .../hr/card-management/[id]/page.tsx | 152 ++--- .../hr/card-management/loading.tsx | 58 ++ .../hr/card-management/new/page.tsx | 36 +- src/app/[locale]/(protected)/loading.tsx | 5 +- .../settings/accounts/[id]/page.tsx | 203 ++---- .../(protected)/settings/accounts/loading.tsx | 46 ++ .../settings/accounts/new/page.tsx | 24 +- .../settings/permissions/loading.tsx | 60 ++ .../{ => _legacy}/CardDetail.tsx | 0 .../CardManagement/{ => _legacy}/CardForm.tsx | 0 .../hr/CardManagement/cardConfig.ts | 165 +++++ .../_legacy/AccountDetail.tsx | 369 +++++++++++ .../AccountManagement/accountConfig.ts | 103 +++ .../FieldRenderer.tsx | 296 +++++++++ .../IntegratedDetailTemplate/index.tsx | 613 ++++++++++++++++++ .../IntegratedDetailTemplate/types.ts | 219 +++++++ src/components/ui/skeleton.tsx | 15 + src/layouts/AuthenticatedLayout.tsx | 2 +- 19 files changed, 2132 insertions(+), 299 deletions(-) create mode 100644 src/app/[locale]/(protected)/hr/card-management/loading.tsx create mode 100644 src/app/[locale]/(protected)/settings/accounts/loading.tsx create mode 100644 src/app/[locale]/(protected)/settings/permissions/loading.tsx rename src/components/hr/CardManagement/{ => _legacy}/CardDetail.tsx (100%) rename src/components/hr/CardManagement/{ => _legacy}/CardForm.tsx (100%) create mode 100644 src/components/hr/CardManagement/cardConfig.ts create mode 100644 src/components/settings/AccountManagement/_legacy/AccountDetail.tsx create mode 100644 src/components/settings/AccountManagement/accountConfig.ts create mode 100644 src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/index.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/types.ts create mode 100644 src/components/ui/skeleton.tsx diff --git a/src/app/[locale]/(protected)/hr/card-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/hr/card-management/[id]/edit/page.tsx index ce079823..edc6ab74 100644 --- a/src/app/[locale]/(protected)/hr/card-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/hr/card-management/[id]/edit/page.tsx @@ -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(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 ; - } - - return ( - - ); -} \ No newline at end of file + return null; +} diff --git a/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx b/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx index 5f6935ee..d449ca62 100644 --- a/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); - const [isDeleting, setIsDeleting] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [error, setError] = useState(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) => { + const result = await updateCard(cardId, data as Partial); + 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 ; + // 에러 상태 + if (error && !isLoading) { + return ( +
+
+ {error} +
+
+ ); } return ( - <> - - - - - - 카드 삭제 - - "{card.cardName}" 카드를 삭제하시겠습니까? -
- - 삭제된 카드 정보는 복구할 수 없습니다. - -
-
- - 취소 - - {isDeleting ? '삭제 중...' : '삭제'} - - -
-
- + ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/hr/card-management/loading.tsx b/src/app/[locale]/(protected)/hr/card-management/loading.tsx new file mode 100644 index 00000000..51c6a92f --- /dev/null +++ b/src/app/[locale]/(protected)/hr/card-management/loading.tsx @@ -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 ( +
+ {/* 헤더 Skeleton */} +
+ + +
+ + {/* 기본 정보 카드 Skeleton */} + + + + + +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ + +
+ ))} +
+
+
+ + {/* 사용자 정보 카드 Skeleton */} + + + + + +
+ + +
+
+
+ + {/* 버튼 영역 Skeleton */} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/hr/card-management/new/page.tsx b/src/app/[locale]/(protected)/hr/card-management/new/page.tsx index 704c3ce3..0a1cbb4b 100644 --- a/src/app/[locale]/(protected)/hr/card-management/new/page.tsx +++ b/src/app/[locale]/(protected)/hr/card-management/new/page.tsx @@ -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) => { + const result = await createCard(data as CardFormData); + return { success: result.success, error: result.error }; }; return ( - ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/loading.tsx b/src/app/[locale]/(protected)/loading.tsx index 9927fc03..0a1d6586 100644 --- a/src/app/[locale]/(protected)/loading.tsx +++ b/src/app/[locale]/(protected)/loading.tsx @@ -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 ; -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx b/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx index 2f23ea43..04f52520 100644 --- a/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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) => { + const result = await updateBankAccount(accountId, data as Partial); + 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 (
- 계좌를 찾을 수 없습니다. + {error}
); } - return ; -} \ No newline at end of file + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/settings/accounts/loading.tsx b/src/app/[locale]/(protected)/settings/accounts/loading.tsx new file mode 100644 index 00000000..44e54b12 --- /dev/null +++ b/src/app/[locale]/(protected)/settings/accounts/loading.tsx @@ -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 ( +
+ {/* 헤더 Skeleton */} +
+ + +
+ + {/* 카드 Skeleton */} + + + + + +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ + +
+ ))} +
+
+
+ + {/* 버튼 영역 Skeleton */} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/settings/accounts/new/page.tsx b/src/app/[locale]/(protected)/settings/accounts/new/page.tsx index c04efd18..bbc0fb7e 100644 --- a/src/app/[locale]/(protected)/settings/accounts/new/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/new/page.tsx @@ -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 ; -} \ No newline at end of file + const handleSubmit = async (data: Record) => { + const result = await createBankAccount(data as AccountFormData); + return { success: result.success, error: result.error }; + }; + + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/settings/permissions/loading.tsx b/src/app/[locale]/(protected)/settings/permissions/loading.tsx new file mode 100644 index 00000000..99feaacc --- /dev/null +++ b/src/app/[locale]/(protected)/settings/permissions/loading.tsx @@ -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 ( +
+ {/* 헤더 Skeleton */} +
+ + +
+ + {/* 검색/필터 영역 Skeleton */} +
+ + +
+ + {/* 테이블 Skeleton */} + + + + + + {/* 테이블 헤더 */} +
+ + + + +
+ {/* 테이블 행들 */} + {[1, 2, 3, 4, 5].map((i) => ( +
+ + + + +
+ ))} +
+
+ + {/* 페이지네이션 Skeleton */} +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/hr/CardManagement/CardDetail.tsx b/src/components/hr/CardManagement/_legacy/CardDetail.tsx similarity index 100% rename from src/components/hr/CardManagement/CardDetail.tsx rename to src/components/hr/CardManagement/_legacy/CardDetail.tsx diff --git a/src/components/hr/CardManagement/CardForm.tsx b/src/components/hr/CardManagement/_legacy/CardForm.tsx similarity index 100% rename from src/components/hr/CardManagement/CardForm.tsx rename to src/components/hr/CardManagement/_legacy/CardForm.tsx diff --git a/src/components/hr/CardManagement/cardConfig.ts b/src/components/hr/CardManagement/cardConfig.ts new file mode 100644 index 00000000..64ed4050 --- /dev/null +++ b/src/components/hr/CardManagement/cardConfig.ts @@ -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 = { + 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 => ({ + 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): Partial => ({ + 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) => { + 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; // 기본 렌더링 사용 + } + }, +}; diff --git a/src/components/settings/AccountManagement/_legacy/AccountDetail.tsx b/src/components/settings/AccountManagement/_legacy/AccountDetail.tsx new file mode 100644 index 00000000..65a4f0c6 --- /dev/null +++ b/src/components/settings/AccountManagement/_legacy/AccountDetail.tsx @@ -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({ + 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 ( + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + {ACCOUNT_STATUS_LABELS[formData.status]} + + + +
+
+
은행
+
{BANK_LABELS[formData.bankCode] || '-'}
+
+
+
계좌번호
+
{formData.accountNumber || '-'}
+
+
+
예금주
+
{formData.accountHolder || '-'}
+
+
+
계좌 비밀번호
+
****
+
+
+
계좌명
+
{formData.accountName || '-'}
+
+
+
상태
+
+ + {ACCOUNT_STATUS_LABELS[formData.status]} + +
+
+
+
+
+ + {/* 버튼 영역 */} +
+ +
+ + +
+
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 계좌 삭제 + + 계좌를 정말 삭제하시겠습니까? +
+ + 삭제된 계좌의 과거 사용 내역은 보존됩니다. + +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); + } + + // 생성/수정 모드 렌더링 + return ( + + + +
+ + + 기본 정보 + + + {/* 은행 & 계좌번호 */} +
+
+ + +
+ +
+ + handleChange('accountNumber', e.target.value)} + placeholder="1234-1234-1234-1234" + disabled={isEditMode} // 수정 시 계좌번호는 변경 불가 + /> +
+
+ + {/* 예금주 & 계좌 비밀번호 */} +
+
+ + handleChange('accountHolder', e.target.value)} + placeholder="예금주명" + /> +
+ +
+ + handleChange('accountPassword', e.target.value)} + placeholder="****" + /> +
+
+ + {/* 계좌명 & 상태 */} +
+
+ + handleChange('accountName', e.target.value)} + placeholder="계좌명을 입력해주세요" + /> +
+ +
+ + +
+
+
+
+ + {/* 버튼 영역 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/settings/AccountManagement/accountConfig.ts b/src/components/settings/AccountManagement/accountConfig.ts new file mode 100644 index 00000000..cceac868 --- /dev/null +++ b/src/components/settings/AccountManagement/accountConfig.ts @@ -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 = { + 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 => ({ + 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): Partial => ({ + 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, + }), +}; diff --git a/src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx b/src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx new file mode 100644 index 00000000..533079db --- /dev/null +++ b/src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx @@ -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 ( +
+ +
+ {renderViewValue(field, value, options)} +
+
+ ); + } + + // Form 모드: 입력 필드 + return ( +
+ + {renderFormField(field, value, onChange, isDisabled, options, error)} + {field.helpText && ( +

{field.helpText}

+ )} + {error &&

{error}

} +
+ ); +} + +// 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 ( +
{String(value)}
+ ); + + 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 ( + onChange(e.target.value)} + placeholder={field.placeholder} + disabled={disabled} + className={cn(error && 'border-red-500')} + /> + ); + + case 'number': + return ( + onChange(e.target.value ? Number(e.target.value) : '')} + placeholder={field.placeholder} + disabled={disabled} + className={cn(error && 'border-red-500')} + /> + ); + + case 'password': + return ( + onChange(e.target.value)} + placeholder={field.placeholder || '****'} + disabled={disabled} + className={cn(error && 'border-red-500')} + /> + ); + + case 'textarea': + return ( +