feat: [accounting] 계정별원장 페이지 신규 추가

This commit is contained in:
유병철
2026-03-20 09:49:41 +09:00
parent 40ee640163
commit 41602a3c1e
5 changed files with 790 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
'use client';
import { AccountLedger } from '@/components/accounting/AccountLedger';
export default function AccountLedgerPage() {
return <AccountLedger />;
}

View File

@@ -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<string, string> = {
journal: '수기전표',
ecard_transaction: '카드거래',
bank_transaction: '은행거래',
hometax: '홈택스',
};
const STATUS_LABELS: Record<string, string> = {
draft: '임시',
confirmed: '확정',
};
export function JournalDetailModal({ open, onOpenChange, item }: JournalDetailModalProps) {
const router = useRouter();
const [detail, setDetail] = useState<JournalEntryDetail | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="text-lg">
{detail?.entry_no && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
{detail.entry_no}
</span>
)}
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleGoToOriginal}
className="mr-6"
>
<ExternalLink className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</DialogHeader>
{isLoading && (
<div className="py-12 text-center">
<Loader2 className="h-6 w-6 mx-auto animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && detail && (
<div className="space-y-4">
{/* 전표 요약 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<InfoItem label="전표번호" value={detail.entry_no} />
<InfoItem label="일자" value={detail.entry_date} />
<InfoItem label="적요" value={detail.description || '-'} />
<InfoItem label="작성자" value={detail.created_by_name || '-'} />
<InfoItem
label="상태"
value={STATUS_LABELS[detail.status] || detail.status}
/>
<InfoItem
label="출처"
value={SOURCE_TYPE_LABELS[detail.source_type || ''] || detail.source_type || '-'}
/>
</div>
{/* 카드 정보 (card_tx가 있을 때) */}
{item?.card_tx && (
<div className="border rounded-lg p-3 bg-blue-50/50">
<div className="flex items-center gap-1.5 mb-2">
<CreditCard className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium"> </span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<InfoItem
label="카드번호"
value={`${item.card_tx.card_company_name} ${maskCardNumber(item.card_tx.card_num)}`}
/>
<InfoItem label="가맹점" value={item.card_tx.merchant_name} />
<InfoItem label="사업자번호" value={item.card_tx.merchant_biz_num} />
<InfoItem
label="공급가액"
value={formatNumber(item.card_tx.supply_amount)}
/>
<InfoItem
label="세액"
value={formatNumber(item.card_tx.tax_amount)}
/>
<div>
<span className="text-muted-foreground"></span>
<div className="mt-0.5">
{item.card_tx.deduction_type === 'deductible' ? (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
</Badge>
) : (
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
</Badge>
)}
</div>
</div>
</div>
</div>
)}
{/* 분개 테이블 */}
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">No</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right w-[110px]"></TableHead>
<TableHead className="text-right w-[110px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.lines?.map((line, idx) => (
<TableRow key={idx}>
<TableCell className="text-sm">{idx + 1}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
line.dc_type === 'debit'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-orange-50 text-orange-700 border-orange-200'
}
>
{line.dc_type === 'debit' ? '차변' : '대변'}
</Badge>
</TableCell>
<TableCell className="text-sm font-mono">{line.account_code}</TableCell>
<TableCell className="text-sm">{line.account_name}</TableCell>
<TableCell className="text-sm">{line.trading_partner_name || ''}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(line.debit_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(line.credit_amount)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{line.description || ''}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow className="font-semibold">
<TableCell colSpan={5} className="text-right">
</TableCell>
<TableCell className="text-right tabular-nums">
{formatLedgerAmount(totalDebit)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatLedgerAmount(totalCredit)}
</TableCell>
<TableCell />
</TableRow>
</TableFooter>
</Table>
</div>
</div>
)}
{!isLoading && !detail && open && (
<div className="py-12 text-center text-muted-foreground">
.
</div>
)}
</DialogContent>
</Dialog>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground">{label}</span>
<p className="font-medium mt-0.5">{value}</p>
</div>
);
}

View File

@@ -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<ActionResult<AccountLedgerResponse>> {
return executeServerAction<AccountLedgerResponse>({
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<ActionResult<JournalEntryDetail>> {
return executeServerAction<JournalEntryDetail>({
url: buildApiUrl(`/api/v1/general-journal-entries/${id}`),
errorMessage: '전표 상세 조회에 실패했습니다.',
});
}

View File

@@ -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<AccountSubject[]>([]);
// 계정과목 목록 로드 (이름 조회용)
useEffect(() => {
getAccountSubjects({ depth: 3 }).then((res) => {
if (res.success && res.data) subjectsRef.current = res.data;
});
}, []);
// 데이터 상태
const [data, setData] = useState<AccountLedgerResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
// 조회 시점 정보 (헤더 표시용)
const [searchedInfo, setSearchedInfo] = useState<{
code: string;
name: string;
startDate: string;
endDate: string;
} | null>(null);
// 드릴다운 모달
const [selectedItem, setSelectedItem] = useState<LedgerItem | null>(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 (
<PageLayout>
<div className="space-y-4">
{/* 헤더 */}
<PageHeader
title="계정별원장"
icon={BookOpen}
actions={
<Button
variant="outline"
size="sm"
onClick={handlePrint}
className="no-print"
disabled={!data}
>
<Printer className="h-4 w-4 mr-1" />
</Button>
}
/>
{/* 조회 필터 */}
<Card className="p-3 md:p-4 no-print">
<div className="flex flex-col gap-3">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
hidePresets
extraActions={
<div className="flex items-center gap-2 w-full xl:flex-1 xl:min-w-0">
<div className="flex-1 min-w-0">
<AccountSubjectSelect
value={accountCode}
onValueChange={setAccountCode}
/>
</div>
<Button onClick={handleSearch} disabled={isLoading} className="shrink-0">
{isLoading ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Search className="h-4 w-4 mr-1" />
)}
</Button>
</div>
}
/>
</div>
</Card>
{/* 원장 테이블 */}
{data && (
<Card className="p-3 md:p-4">
{/* 계정 정보 헤더 */}
{searchedInfo && (
<div className="mb-3">
<span className="text-primary font-semibold">{searchedInfo.code}</span>
{searchedInfo.name && (
<span className="font-semibold ml-2">{searchedInfo.name}</span>
)}
<span className="text-sm text-muted-foreground ml-3">
({searchedInfo.startDate} ~ {searchedInfo.endDate})
</span>
</div>
)}
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="min-w-[200px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px] text-right"></TableHead>
<TableHead className="w-[110px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* 거래 내역이 없을 때 */}
{!hasTransactions && (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{/* 거래 내역이 있을 때 */}
{hasTransactions && (
<>
{/* 이월잔액 */}
<TableRow className={hasCarryForward ? 'bg-yellow-50 font-semibold' : 'bg-yellow-50/50'}>
<TableCell>-</TableCell>
<TableCell className={hasCarryForward ? '' : 'text-muted-foreground'}>
</TableCell>
<TableCell />
<TableCell />
<TableCell className="text-right">
{formatLedgerAmount(data.carry_forward.debit)}
</TableCell>
<TableCell className="text-right">
{formatLedgerAmount(data.carry_forward.credit)}
</TableCell>
<TableCell className="text-right">
{formatLedgerAmount(data.carry_forward.balance) || '0'}
</TableCell>
</TableRow>
{/* 월별 데이터 */}
{data.monthly_data.map((month) => (
<MonthBlock
key={month.month}
month={month}
onRowClick={handleRowClick}
/>
))}
{/* 총합계 */}
<TableRow className="bg-indigo-50 font-semibold">
<TableCell />
<TableCell> </TableCell>
<TableCell />
<TableCell />
<TableCell className="text-right">
{formatLedgerAmount(data.grand_total.debit)}
</TableCell>
<TableCell className="text-right">
{formatLedgerAmount(data.grand_total.credit)}
</TableCell>
<TableCell className="text-right">
{formatLedgerAmount(data.grand_total.balance)}
</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
</div>
</Card>
)}
{/* 빈 상태 */}
{!data && !isLoading && (
<Card className="p-12 text-center text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p> .</p>
</Card>
)}
{/* 로딩 */}
{isLoading && (
<Card className="p-12 text-center text-muted-foreground">
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin" />
<p> ...</p>
</Card>
)}
</div>
{/* 전표 상세 모달 */}
<JournalDetailModal
open={modalOpen}
onOpenChange={setModalOpen}
item={selectedItem}
/>
</PageLayout>
);
}
// ===== 월별 블록 서브 컴포넌트 =====
function MonthBlock({
month,
onRowClick,
}: {
month: AccountLedgerResponse['monthly_data'][number];
onRowClick: (item: LedgerItem) => void;
}) {
return (
<>
{/* 거래 행 */}
{month.items.map((item, idx) => (
<TableRow
key={`${month.month}-${idx}`}
className={
item.source_type !== 'none'
? 'cursor-pointer hover:bg-muted/50 transition-colors'
: ''
}
onClick={() => onRowClick(item)}
>
<TableCell className="text-sm whitespace-nowrap">
{item.date.slice(5)} {/* MM-DD */}
</TableCell>
<TableCell>
{item.card_tx ? (
<div>
<div className="flex items-center gap-1.5">
<CreditCard className="h-3.5 w-3.5 text-blue-500 shrink-0" />
<span className="text-sm">{item.description || '-'}</span>
<DeductionBadge type={item.card_tx.deduction_type} />
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{item.card_tx.card_company_name} {maskCardNumber(item.card_tx.card_num)}
</div>
</div>
) : (
<span className="text-sm">{item.description || '-'}</span>
)}
</TableCell>
<TableCell className="text-sm">
{item.card_tx?.merchant_name || item.trading_partner_name || ''}
</TableCell>
<TableCell className="text-sm font-mono">
{item.card_tx?.merchant_biz_num || item.biz_no || ''}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(item.debit_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(item.credit_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums font-medium">
{formatLedgerAmount(item.balance)}
</TableCell>
</TableRow>
))}
{/* 월 소계 */}
<TableRow className="bg-gray-50 font-semibold">
<TableCell />
<TableCell className="text-sm">{formatMonth(month.month)} </TableCell>
<TableCell />
<TableCell />
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(month.subtotal.debit)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(month.subtotal.credit)}
</TableCell>
<TableCell />
</TableRow>
{/* 누계 */}
<TableRow className="bg-gray-50 font-semibold">
<TableCell />
<TableCell className="text-sm pl-6"> </TableCell>
<TableCell />
<TableCell />
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(month.cumulative.debit)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatLedgerAmount(month.cumulative.credit)}
</TableCell>
<TableCell />
</TableRow>
</>
);
}
// ===== 공제/불공제 배지 =====
function DeductionBadge({ type }: { type: string }) {
if (type === 'deductible') {
return (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 bg-green-50 text-green-700 border-green-200">
</Badge>
);
}
if (type === 'non_deductible') {
return (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 bg-red-50 text-red-700 border-red-200">
</Badge>
);
}
return null;
}

View File

@@ -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);
}