feat: [accounting] 계정별원장 페이지 신규 추가
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { AccountLedger } from '@/components/accounting/AccountLedger';
|
||||
|
||||
export default function AccountLedgerPage() {
|
||||
return <AccountLedger />;
|
||||
}
|
||||
244
src/components/accounting/AccountLedger/JournalDetailModal.tsx
Normal file
244
src/components/accounting/AccountLedger/JournalDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/accounting/AccountLedger/actions.ts
Normal file
31
src/components/accounting/AccountLedger/actions.ts
Normal 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: '전표 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
415
src/components/accounting/AccountLedger/index.tsx
Normal file
415
src/components/accounting/AccountLedger/index.tsx
Normal 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;
|
||||
}
|
||||
93
src/components/accounting/AccountLedger/types.ts
Normal file
93
src/components/accounting/AccountLedger/types.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user