diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx new file mode 100644 index 00000000..78cc3685 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { use } from 'react'; +import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient'; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default function CardTransactionEditPage({ params }: PageProps) { + const { id } = use(params); + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx new file mode 100644 index 00000000..1701bef1 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { use } from 'react'; +import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient'; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default function CardTransactionDetailPage({ params }: PageProps) { + const { id } = use(params); + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx new file mode 100644 index 00000000..396e55fc --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient'; + +export default function CardTransactionNewPage() { + return ; +} diff --git a/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx b/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx new file mode 100644 index 00000000..89bf53a4 --- /dev/null +++ b/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types'; +import { cardTransactionDetailConfig } from './cardTransactionDetailConfig'; +import type { CardTransaction } from './types'; +import { + getCardTransactionById, + createCardTransaction, + updateCardTransaction, + deleteCardTransaction, +} from './actions'; + +// ===== Props ===== +interface CardTransactionDetailClientProps { + transactionId?: string; + initialMode?: DetailMode; +} + +export default function CardTransactionDetailClient({ + transactionId, + initialMode = 'view', +}: CardTransactionDetailClientProps) { + const router = useRouter(); + const [mode, setMode] = useState(initialMode); + const [transaction, setTransaction] = useState(null); + const [isLoading, setIsLoading] = useState(initialMode !== 'create'); + + // ===== 데이터 로드 ===== + useEffect(() => { + const loadTransaction = async () => { + if (transactionId && initialMode !== 'create') { + setIsLoading(true); + const result = await getCardTransactionById(transactionId); + if (result.success && result.data) { + setTransaction(result.data); + } else { + toast.error(result.error || '카드 사용내역을 불러오는데 실패했습니다.'); + } + setIsLoading(false); + } + }; + loadTransaction(); + }, [transactionId, initialMode]); + + // ===== 저장/등록 핸들러 ===== + const handleSubmit = useCallback( + async (formData: Record): Promise<{ success: boolean; error?: string }> => { + const submitData = cardTransactionDetailConfig.transformSubmitData?.(formData) || formData; + + if (!submitData.merchantName) { + toast.error('가맹점명을 입력해주세요.'); + return { success: false, error: '가맹점명을 입력해주세요.' }; + } + if (!submitData.amount || Number(submitData.amount) <= 0) { + toast.error('사용금액을 입력해주세요.'); + return { success: false, error: '사용금액을 입력해주세요.' }; + } + if (!submitData.usedAt) { + toast.error('사용일시를 선택해주세요.'); + return { success: false, error: '사용일시를 선택해주세요.' }; + } + + const result = + mode === 'create' + ? await createCardTransaction(submitData as Parameters[0]) + : await updateCardTransaction(transactionId!, submitData as Parameters[1]); + + if (result.success) { + toast.success(mode === 'create' ? '카드 사용내역이 등록되었습니다.' : '카드 사용내역이 수정되었습니다.'); + router.push('/ko/accounting/card-transactions'); + return { success: true }; + } else { + toast.error(result.error || '저장에 실패했습니다.'); + return { success: false, error: result.error }; + } + }, + [mode, transactionId, router] + ); + + // ===== 삭제 핸들러 ===== + const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + if (!transactionId) return { success: false, error: 'ID가 없습니다.' }; + + const result = await deleteCardTransaction(transactionId); + if (result.success) { + toast.success('카드 사용내역이 삭제되었습니다.'); + router.push('/ko/accounting/card-transactions'); + return { success: true }; + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + return { success: false, error: result.error }; + } + }, [transactionId, router]); + + // ===== 모드 변경 핸들러 ===== + const handleModeChange = useCallback( + (newMode: DetailMode) => { + if (newMode === 'edit' && transactionId) { + router.push(`/ko/accounting/card-transactions/${transactionId}/edit`); + } else { + setMode(newMode); + } + }, + [transactionId, router] + ); + + return ( + [0]['config']} + mode={mode} + initialData={transaction as unknown as Record | undefined} + itemId={transactionId} + isLoading={isLoading} + onSubmit={handleSubmit} + onDelete={handleDelete} + onModeChange={handleModeChange} + buttonPosition="top" + /> + ); +} diff --git a/src/components/accounting/CardTransactionInquiry/actions.ts b/src/components/accounting/CardTransactionInquiry/actions.ts index 5a3e5465..c27937de 100644 --- a/src/components/accounting/CardTransactionInquiry/actions.ts +++ b/src/components/accounting/CardTransactionInquiry/actions.ts @@ -238,6 +238,220 @@ export async function getCardTransactionSummary(params?: { } } +// ===== 카드 거래 단건 조회 ===== +export async function getCardTransactionById(id: string): Promise<{ + success: boolean; + data?: CardTransaction; + error?: string; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '조회에 실패했습니다.' }; + } + + return { + success: true, + data: transformItem(result.data), + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[CardTransactionActions] getCardTransactionById error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +// ===== 카드 거래 등록 ===== +export async function createCardTransaction(data: { + cardId?: number; + usedAt: string; + merchantName: string; + amount: number; + memo?: string; + usageType?: string; +}): Promise<{ + success: boolean; + data?: CardTransaction; + error?: string; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify({ + card_id: data.cardId, + used_at: data.usedAt, + merchant_name: data.merchantName, + amount: data.amount, + description: data.memo, + account_code: data.usageType, + }), + }); + + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '등록에 실패했습니다.' }; + } + + return { + success: true, + data: transformItem(result.data), + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[CardTransactionActions] createCardTransaction error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +// ===== 카드 거래 수정 ===== +export async function updateCardTransaction( + id: string, + data: { + usedAt?: string; + merchantName?: string; + amount?: number; + memo?: string; + usageType?: string; + } +): Promise<{ + success: boolean; + data?: CardTransaction; + error?: string; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions/${id}`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify({ + used_at: data.usedAt, + merchant_name: data.merchantName, + amount: data.amount, + description: data.memo, + account_code: data.usageType, + }), + }); + + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '수정에 실패했습니다.' }; + } + + return { + success: true, + data: transformItem(result.data), + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[CardTransactionActions] updateCardTransaction error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +// ===== 카드 거래 삭제 ===== +export async function deleteCardTransaction(id: string): Promise<{ + success: boolean; + error?: string; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); + + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '삭제에 실패했습니다.' }; + } + + return { success: true }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[CardTransactionActions] deleteCardTransaction error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +// ===== 카드 목록 조회 (등록 폼용) ===== +export async function getCardList(): Promise<{ + success: boolean; + data: Array<{ id: number; name: string; cardNumber: string }>; + error?: string; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/cards`; + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, data: [], error: result.message }; + } + + const cards = (result.data?.data || result.data || []).map((card: { + id: number; + card_name: string; + card_company: string; + card_number_last4: string; + }) => ({ + id: card.id, + name: card.card_name, + cardNumber: `${card.card_company} ${card.card_number_last4}`, + })); + + return { success: true, data: cards }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[CardTransactionActions] getCardList error:', error); + return { success: false, data: [], error: '서버 오류가 발생했습니다.' }; + } +} + // ===== 계정과목 일괄 수정 ===== export async function bulkUpdateAccountCode( ids: number[], diff --git a/src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts b/src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts new file mode 100644 index 00000000..5033a303 --- /dev/null +++ b/src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts @@ -0,0 +1,125 @@ +import { CreditCard } from 'lucide-react'; +import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { CardTransaction } from './types'; +import { USAGE_TYPE_OPTIONS } from './types'; +import { getCardList } from './actions'; + +// ===== 필드 정의 ===== +const fields: FieldDefinition[] = [ + // 카드 선택 (create에서만 선택 가능) + { + key: 'cardId', + label: '카드', + type: 'select', + required: true, + placeholder: '카드를 선택해주세요', + fetchOptions: async () => { + const result = await getCardList(); + if (result.success) { + return result.data.map((card) => ({ + value: String(card.id), + label: `${card.name} (${card.cardNumber})`, + })); + } + return []; + }, + disabled: (mode) => mode === 'view', + }, + // 사용일시 + { + key: 'usedAt', + label: '사용일시', + type: 'datetime-local', + required: true, + placeholder: '사용일시를 선택해주세요', + disabled: (mode) => mode === 'view', + }, + // 가맹점명 + { + key: 'merchantName', + label: '가맹점명', + type: 'text', + required: true, + placeholder: '가맹점명을 입력해주세요', + disabled: (mode) => mode === 'view', + }, + // 사용금액 + { + key: 'amount', + label: '사용금액', + type: 'number', + required: true, + placeholder: '사용금액을 입력해주세요', + disabled: (mode) => mode === 'view', + }, + // 적요 + { + key: 'memo', + label: '적요', + type: 'text', + placeholder: '적요를 입력해주세요', + gridSpan: 2, + disabled: (mode) => mode === 'view', + }, + // 사용유형 + { + key: 'usageType', + label: '사용유형', + type: 'select', + placeholder: '선택', + options: USAGE_TYPE_OPTIONS.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + disabled: (mode) => mode === 'view', + }, +]; + +// ===== Config 정의 ===== +export const cardTransactionDetailConfig: DetailConfig = { + title: '카드 사용내역', + description: '카드 사용 내역을 등록/수정합니다', + icon: CreditCard, + basePath: '/accounting/card-transactions', + fields, + gridColumns: 2, + actions: { + showBack: true, + showDelete: true, + showEdit: true, + backLabel: '목록', + deleteLabel: '삭제', + editLabel: '수정', + deleteConfirmMessage: { + title: '카드 사용내역 삭제', + description: '이 카드 사용내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + }, + }, + transformInitialData: (data: Record): Record => { + const record = data as unknown as CardTransaction; + // usedAt을 datetime-local 형식으로 변환 (YYYY-MM-DDTHH:mm) + let usedAtFormatted = ''; + if (record.usedAt) { + // "2025-01-22 14:30" 형식을 "2025-01-22T14:30" 형식으로 변환 + usedAtFormatted = record.usedAt.replace(' ', 'T').slice(0, 16); + } + return { + cardId: record.id ? '' : '', // 수정 시에는 카드 변경 불가 + usedAt: usedAtFormatted, + merchantName: record.merchantName || '', + amount: record.amount || 0, + memo: record.memo || '', + usageType: record.usageType || 'unset', + }; + }, + transformSubmitData: (formData: Record) => { + return { + cardId: formData.cardId ? Number(formData.cardId) : undefined, + usedAt: formData.usedAt as string, + merchantName: formData.merchantName as string, + amount: Number(formData.amount), + memo: formData.memo as string, + usageType: formData.usageType as string, + }; + }, +}; \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 3b042730..eccb3fba 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -15,8 +15,9 @@ */ import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; -import { CreditCard, RefreshCw, Save, Loader2 } from 'lucide-react'; +import { CreditCard, Plus, RefreshCw, Save, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; @@ -94,6 +95,8 @@ export function CardTransactionInquiry({ initialSummary, initialPagination, }: CardTransactionInquiryProps) { + const router = useRouter(); + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [data, setData] = useState(initialData); const [summary, setSummary] = useState( @@ -377,6 +380,14 @@ export function CardTransactionInquiry({ }, filterTitle: '카드 필터', + // 헤더 액션 (등록 버튼) + headerActions: () => ( + + ), + // 커스텀 필터 함수 customFilterFn: (items) => { if (cardFilter === 'all') return items; @@ -580,6 +591,7 @@ export function CardTransactionInquiry({ totalAmount, selectedAccountSubject, isLoading, + router, handleRowClick, handleRefresh, handleSaveAccountSubject, diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 93ef4880..d229c783 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -3,8 +3,8 @@ import { useState, useCallback, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { LayoutDashboard, Settings } from 'lucide-react'; -import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { Button } from '@/components/ui/button'; +import { CEODashboardSkeleton } from './skeletons'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { @@ -244,7 +244,7 @@ export function CEODashboard() { description="전체 현황을 조회합니다." icon={LayoutDashboard} /> - + ); } diff --git a/src/components/business/CEODashboard/skeletons/index.tsx b/src/components/business/CEODashboard/skeletons/index.tsx new file mode 100644 index 00000000..721cc56a --- /dev/null +++ b/src/components/business/CEODashboard/skeletons/index.tsx @@ -0,0 +1,279 @@ +/** + * CEODashboard Skeleton Components + * + * 대시보드 로딩 시 실제 레이아웃과 동일한 형태의 스켈레톤 + */ + +'use client'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +// ============================================ +// 1. 오늘의 이슈 섹션 스켈레톤 +// ============================================ +export function TodayIssueSectionSkeleton() { + return ( + + + {/* 헤더 */} +
+ + +
+ + {/* 리스트 그리드 */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ ); +} + +// ============================================ +// 2. 일일 일보 섹션 스켈레톤 +// ============================================ +export function DailyReportSectionSkeleton() { + return ( + + + {/* 헤더 */} +
+
+ + +
+ +
+ + {/* 4개 카드 그리드 */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* 체크포인트 */} +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+ ); +} + +// ============================================ +// 3. 현황판 섹션 스켈레톤 +// ============================================ +export function StatusBoardSectionSkeleton() { + return ( + + + {/* 섹션 타이틀 */} +
+ + +
+ + {/* 카드 그리드 */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ ); +} + +// ============================================ +// 4. 당월 예상 지출 섹션 스켈레톤 +// ============================================ +export function MonthlyExpenseSectionSkeleton() { + return ( + + + {/* 헤더 */} +
+ + +
+ + {/* 카드 그리드 */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+
+
+ ); +} + +// ============================================ +// 5. 카드/가지급금 관리 섹션 스켈레톤 +// ============================================ +export function CardManagementSectionSkeleton() { + return ( + + + {/* 헤더 */} +
+ +
+ + {/* 카드 그리드 */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+
+
+ ); +} + +// ============================================ +// 6. 미수금/채권추심/접대비/복리후생비 공통 스켈레톤 +// ============================================ +export function AmountSectionSkeleton({ cardCount = 4 }: { cardCount?: number }) { + return ( + + + {/* 헤더 */} +
+ + +
+ + {/* 카드 그리드 */} +
+ {Array.from({ length: cardCount }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ ); +} + +// ============================================ +// 7. 캘린더 섹션 스켈레톤 +// ============================================ +export function CalendarSectionSkeleton() { + return ( + + + {/* 헤더 */} +
+ +
+ + + +
+
+ + {/* 캘린더 그리드 */} +
+ {/* 요일 헤더 */} +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ {/* 날짜 그리드 */} + {Array.from({ length: 5 }).map((_, row) => ( +
+ {Array.from({ length: 7 }).map((_, col) => ( + + ))} +
+ ))} +
+
+
+ ); +} + +// ============================================ +// 8. 전체 대시보드 스켈레톤 +// ============================================ +export function CEODashboardSkeleton() { + return ( +
+ {/* 오늘의 이슈 */} + + + {/* 일일 일보 */} + + + {/* 현황판 */} + + + {/* 당월 예상 지출 */} + + + {/* 카드/가지급금 관리 */} + + + {/* 접대비 현황 */} + + + {/* 복리후생비 현황 */} + + + {/* 미수금 현황 */} + + + {/* 채권추심 현황 */} + + + {/* 부가세 현황 */} + + + {/* 캘린더 */} + +
+ ); +} + +export default CEODashboardSkeleton;