From 41602a3c1eee8621978b08d25ff4090be989d80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 20 Mar 2026 09:49:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[accounting]=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EB=B3=84=EC=9B=90=EC=9E=A5=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../accounting/account-ledger/page.tsx | 7 + .../AccountLedger/JournalDetailModal.tsx | 244 ++++++++++ .../accounting/AccountLedger/actions.ts | 31 ++ .../accounting/AccountLedger/index.tsx | 415 ++++++++++++++++++ .../accounting/AccountLedger/types.ts | 93 ++++ 5 files changed, 790 insertions(+) create mode 100644 src/app/[locale]/(protected)/accounting/account-ledger/page.tsx create mode 100644 src/components/accounting/AccountLedger/JournalDetailModal.tsx create mode 100644 src/components/accounting/AccountLedger/actions.ts create mode 100644 src/components/accounting/AccountLedger/index.tsx create mode 100644 src/components/accounting/AccountLedger/types.ts diff --git a/src/app/[locale]/(protected)/accounting/account-ledger/page.tsx b/src/app/[locale]/(protected)/accounting/account-ledger/page.tsx new file mode 100644 index 00000000..6ad8b66c --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/account-ledger/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { AccountLedger } from '@/components/accounting/AccountLedger'; + +export default function AccountLedgerPage() { + return ; +} diff --git a/src/components/accounting/AccountLedger/JournalDetailModal.tsx b/src/components/accounting/AccountLedger/JournalDetailModal.tsx new file mode 100644 index 00000000..c6c7afad --- /dev/null +++ b/src/components/accounting/AccountLedger/JournalDetailModal.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ExternalLink, Loader2, CreditCard } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { formatNumber } from '@/lib/utils/amount'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from '@/components/ui/table'; +import { getJournalEntryDetail } from './actions'; +import type { LedgerItem, JournalEntryDetail } from './types'; +import { formatLedgerAmount, maskCardNumber } from './types'; + +interface JournalDetailModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + item: LedgerItem | null; +} + +const SOURCE_TYPE_LABELS: Record = { + journal: '수기전표', + ecard_transaction: '카드거래', + bank_transaction: '은행거래', + hometax: '홈택스', +}; + +const STATUS_LABELS: Record = { + draft: '임시', + confirmed: '확정', +}; + +export function JournalDetailModal({ open, onOpenChange, item }: JournalDetailModalProps) { + const router = useRouter(); + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (open && item?.source_id) { + setIsLoading(true); + setDetail(null); + getJournalEntryDetail(item.source_id) + .then((result) => { + if (result.success && result.data) { + setDetail(result.data); + } + }) + .finally(() => setIsLoading(false)); + } + }, [open, item?.source_id]); + + const handleGoToOriginal = () => { + onOpenChange(false); + router.push('/accounting/general-journal-entry'); + }; + + const totalDebit = detail?.lines?.reduce((sum, l) => sum + l.debit_amount, 0) ?? 0; + const totalCredit = detail?.lines?.reduce((sum, l) => sum + l.credit_amount, 0) ?? 0; + + return ( + + + +
+ + 전표 상세 + {detail?.entry_no && ( + + {detail.entry_no} + + )} + + +
+
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && detail && ( +
+ {/* 전표 요약 */} +
+ + + + + + +
+ + {/* 카드 정보 (card_tx가 있을 때) */} + {item?.card_tx && ( +
+
+ + 카드 거래 정보 +
+
+ + + + + +
+ 공제여부 +
+ {item.card_tx.deduction_type === 'deductible' ? ( + + 공제 + + ) : ( + + 불공제 + + )} +
+
+
+
+ )} + + {/* 분개 테이블 */} +
+ + + + No + 구분 + 계정코드 + 계정명 + 거래처 + 차변 + 대변 + 적요 + + + + {detail.lines?.map((line, idx) => ( + + {idx + 1} + + + {line.dc_type === 'debit' ? '차변' : '대변'} + + + {line.account_code} + {line.account_name} + {line.trading_partner_name || ''} + + {formatLedgerAmount(line.debit_amount)} + + + {formatLedgerAmount(line.credit_amount)} + + + {line.description || ''} + + + ))} + + + + + 합계 + + + {formatLedgerAmount(totalDebit)} + + + {formatLedgerAmount(totalCredit)} + + + + +
+
+
+ )} + + {!isLoading && !detail && open && ( +
+ 전표 정보를 불러올 수 없습니다. +
+ )} +
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/src/components/accounting/AccountLedger/actions.ts b/src/components/accounting/AccountLedger/actions.ts new file mode 100644 index 00000000..a138fcf3 --- /dev/null +++ b/src/components/accounting/AccountLedger/actions.ts @@ -0,0 +1,31 @@ +'use server'; + +import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import { buildApiUrl } from '@/lib/api/query-params'; +import type { AccountLedgerResponse, JournalEntryDetail } from './types'; + +// ===== 계정별원장 조회 ===== +export async function getAccountLedger(params: { + startDate: string; + endDate: string; + accountCode: string; +}): Promise> { + return executeServerAction({ + url: buildApiUrl('/api/v1/account-ledger', { + start_date: params.startDate, + end_date: params.endDate, + account_code: params.accountCode, + }), + errorMessage: '계정별원장 조회에 실패했습니다.', + }); +} + +// ===== 전표 상세 조회 (드릴다운) ===== +export async function getJournalEntryDetail( + id: number +): Promise> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/general-journal-entries/${id}`), + errorMessage: '전표 상세 조회에 실패했습니다.', + }); +} diff --git a/src/components/accounting/AccountLedger/index.tsx b/src/components/accounting/AccountLedger/index.tsx new file mode 100644 index 00000000..6f08c196 --- /dev/null +++ b/src/components/accounting/AccountLedger/index.tsx @@ -0,0 +1,415 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { BookOpen, Printer, Search, Loader2, CreditCard } from 'lucide-react'; +import { toast } from 'sonner'; +import { PageHeader } from '@/components/organisms'; +import { PageLayout } from '@/components/organisms'; +import { Button } from '@/components/ui/button'; +import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +import { Card } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { AccountSubjectSelect } from '@/components/accounting/common'; +import { getAccountSubjects } from '@/components/accounting/common/actions'; +import type { AccountSubject } from '@/components/accounting/common/types'; +import { getAccountLedger } from './actions'; +import { JournalDetailModal } from './JournalDetailModal'; +import type { AccountLedgerResponse, LedgerItem } from './types'; +import { formatLedgerAmount, maskCardNumber } from './types'; + +function getDefaultStartDate(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; +} + +function getDefaultEndDate(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} + +function formatMonth(month: string): string { + // "2026-01" → "2026년 01월" + const [y, m] = month.split('-'); + return `${y}년 ${m}월`; +} + +export function AccountLedger() { + // 필터 상태 + const [startDate, setStartDate] = useState(getDefaultStartDate); + const [endDate, setEndDate] = useState(getDefaultEndDate); + const [accountCode, setAccountCode] = useState(''); + const subjectsRef = useRef([]); + + // 계정과목 목록 로드 (이름 조회용) + useEffect(() => { + getAccountSubjects({ depth: 3 }).then((res) => { + if (res.success && res.data) subjectsRef.current = res.data; + }); + }, []); + + // 데이터 상태 + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // 조회 시점 정보 (헤더 표시용) + const [searchedInfo, setSearchedInfo] = useState<{ + code: string; + name: string; + startDate: string; + endDate: string; + } | null>(null); + + // 드릴다운 모달 + const [selectedItem, setSelectedItem] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const handleSearch = useCallback(async () => { + if (!accountCode) { + toast.error('계정과목을 선택해주세요.'); + return; + } + if (!startDate || !endDate) { + toast.error('조회기간을 입력해주세요.'); + return; + } + + setIsLoading(true); + try { + const result = await getAccountLedger({ + startDate, + endDate, + accountCode, + }); + if (result.success && result.data) { + setData(result.data); + const subjectName = result.data.account?.name + || subjectsRef.current.find((s) => s.code === accountCode)?.name + || ''; + setSearchedInfo({ + code: result.data.account?.code || accountCode, + name: subjectName, + startDate, + endDate, + }); + } else { + toast.error(result.error || '조회에 실패했습니다.'); + setData(null); + } + } catch { + toast.error('조회 중 오류가 발생했습니다.'); + setData(null); + } finally { + setIsLoading(false); + } + }, [startDate, endDate, accountCode]); + + const handleRowClick = (item: LedgerItem) => { + if (['journal', 'ecard_transaction', 'bank_transaction'].includes(item.source_type)) { + setSelectedItem(item); + setModalOpen(true); + } + }; + + const handlePrint = () => { + window.print(); + }; + + const hasCarryForward = data && ( + data.carry_forward.debit !== 0 || + data.carry_forward.credit !== 0 || + data.carry_forward.balance !== 0 + ); + + const hasTransactions = data && data.monthly_data.length > 0 && + data.monthly_data.some((m) => m.items.length > 0); + + return ( + +
+ {/* 헤더 */} + + + 인쇄 + + } + /> + + {/* 조회 필터 */} + +
+ +
+ +
+ +
+ } + /> +
+ + + {/* 원장 테이블 */} + {data && ( + + {/* 계정 정보 헤더 */} + {searchedInfo && ( +
+ {searchedInfo.code} + {searchedInfo.name && ( + {searchedInfo.name} + )} + + ({searchedInfo.startDate} ~ {searchedInfo.endDate}) + +
+ )} + +
+ + + + 날짜 + 적요 + 거래처 + 사업자번호 + 차변 + 대변 + 잔액 + + + + {/* 거래 내역이 없을 때 */} + {!hasTransactions && ( + + + 조회 기간 내 거래 내역이 없습니다. + + + )} + + {/* 거래 내역이 있을 때 */} + {hasTransactions && ( + <> + {/* 이월잔액 */} + + - + + 이월잔액 + + + + + {formatLedgerAmount(data.carry_forward.debit)} + + + {formatLedgerAmount(data.carry_forward.credit)} + + + {formatLedgerAmount(data.carry_forward.balance) || '0'} + + + + {/* 월별 데이터 */} + {data.monthly_data.map((month) => ( + + ))} + + {/* 총합계 */} + + + 총 합 계 + + + + {formatLedgerAmount(data.grand_total.debit)} + + + {formatLedgerAmount(data.grand_total.credit)} + + + {formatLedgerAmount(data.grand_total.balance)} + + + + )} + +
+
+
+ )} + + {/* 빈 상태 */} + {!data && !isLoading && ( + + +

계정과목을 선택하고 조회 버튼을 클릭하세요.

+
+ )} + + {/* 로딩 */} + {isLoading && ( + + +

조회 중...

+
+ )} + + + {/* 전표 상세 모달 */} + +
+ ); +} + +// ===== 월별 블록 서브 컴포넌트 ===== +function MonthBlock({ + month, + onRowClick, +}: { + month: AccountLedgerResponse['monthly_data'][number]; + onRowClick: (item: LedgerItem) => void; +}) { + return ( + <> + {/* 거래 행 */} + {month.items.map((item, idx) => ( + onRowClick(item)} + > + + {item.date.slice(5)} {/* MM-DD */} + + + {item.card_tx ? ( +
+
+ + {item.description || '-'} + +
+
+ {item.card_tx.card_company_name} {maskCardNumber(item.card_tx.card_num)} +
+
+ ) : ( + {item.description || '-'} + )} +
+ + {item.card_tx?.merchant_name || item.trading_partner_name || ''} + + + {item.card_tx?.merchant_biz_num || item.biz_no || ''} + + + {formatLedgerAmount(item.debit_amount)} + + + {formatLedgerAmount(item.credit_amount)} + + + {formatLedgerAmount(item.balance)} + +
+ ))} + + {/* 월 소계 */} + + + {formatMonth(month.month)} 계 + + + + {formatLedgerAmount(month.subtotal.debit)} + + + {formatLedgerAmount(month.subtotal.credit)} + + + + + {/* 누계 */} + + + 누 계 + + + + {formatLedgerAmount(month.cumulative.debit)} + + + {formatLedgerAmount(month.cumulative.credit)} + + + + + ); +} + +// ===== 공제/불공제 배지 ===== +function DeductionBadge({ type }: { type: string }) { + if (type === 'deductible') { + return ( + + 공제 + + ); + } + if (type === 'non_deductible') { + return ( + + 불공제 + + ); + } + return null; +} diff --git a/src/components/accounting/AccountLedger/types.ts b/src/components/accounting/AccountLedger/types.ts new file mode 100644 index 00000000..498e6ad2 --- /dev/null +++ b/src/components/accounting/AccountLedger/types.ts @@ -0,0 +1,93 @@ +import type { AccountSubjectCategory } from '@/components/accounting/common/types'; + +// ===== 계정별원장 API 응답 ===== +export interface AccountLedgerResponse { + account: { + code: string; + name: string; + category: AccountSubjectCategory; + } | null; + period: { + start_date: string; + end_date: string; + }; + carry_forward: { + debit: number; + credit: number; + balance: number; + }; + monthly_data: MonthlyData[]; + grand_total: { + debit: number; + credit: number; + balance: number; + }; +} + +export interface MonthlyData { + month: string; // "2026-03" + items: LedgerItem[]; + subtotal: { debit: number; credit: number }; + cumulative: { debit: number; credit: number }; +} + +export interface LedgerItem { + date: string; + description: string | null; + trading_partner_name: string | null; + biz_no: string | null; + debit_amount: number; + credit_amount: number; + balance: number; + source_type: string; + source_id: number; + card_tx: CardTransaction | null; +} + +export interface CardTransaction { + card_num: string; + card_company_name: string; + merchant_name: string; + merchant_biz_num: string; + deduction_type: string; // 'deductible' | 'non_deductible' + supply_amount: number; + tax_amount: number; + approval_amount: number; +} + +// ===== 전표 상세 (드릴다운 모달) ===== +export interface JournalEntryDetail { + id: number; + entry_no: string; + entry_date: string; + entry_type: string; + description: string | null; + total_debit: number; + total_credit: number; + status: 'draft' | 'confirmed'; + source_type: string | null; + created_by_name: string | null; + lines: JournalEntryLine[]; +} + +export interface JournalEntryLine { + line_no: number; + dc_type: 'debit' | 'credit'; + account_code: string; + account_name: string; + trading_partner_name: string | null; + debit_amount: number; + credit_amount: number; + description: string | null; +} + +// ===== 숫자 포맷 유틸 ===== +export function formatLedgerAmount(n: number | null | undefined): string { + if (n === 0 || n === null || n === undefined) return ''; + if (n < 0) return '(' + Math.abs(n).toLocaleString() + ')'; + return n.toLocaleString(); +} + +export function maskCardNumber(cardNum: string): string { + return '\u00B7\u00B7\u00B7\u00B7' + cardNum.slice(-4); +}