258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useStore } from '../store';
|
|
import { buildLedger, getAvailableMonths, filterByMonth, formatAmount, calcAccountBalance } from '../utils/bookkeeping';
|
|
import { ACCOUNT_TYPE_LABELS } from '../utils/accounts';
|
|
import type { AccountType } from '../types';
|
|
|
|
const TYPE_ORDER: AccountType[] = ['asset', 'liability', 'equity', 'income', 'expense'];
|
|
|
|
export default function LedgerPage() {
|
|
const {
|
|
accounts, transactions, openingBalances,
|
|
selectedMonth, setSelectedMonth,
|
|
selectedLedgerAccountId: selectedAccountId,
|
|
setSelectedLedgerAccountId: setSelectedAccountId,
|
|
previousPage, goBack,
|
|
hideInactiveLedgerAccounts, setHideInactiveLedgerAccounts,
|
|
} = useStore();
|
|
|
|
const availableMonths = useMemo(() => getAvailableMonths(transactions), [transactions]);
|
|
const effectiveMonth = selectedMonth;
|
|
|
|
const [year, month] = effectiveMonth
|
|
? [parseInt(effectiveMonth.split('-')[0]), parseInt(effectiveMonth.split('-')[1])]
|
|
: [0, 0];
|
|
|
|
// 対象取引
|
|
const targetTxs = useMemo(
|
|
() => effectiveMonth ? filterByMonth(transactions, year, month) : transactions,
|
|
[transactions, effectiveMonth, year, month]
|
|
);
|
|
|
|
// 勘定科目グループ
|
|
const accountGroups = useMemo(() => {
|
|
return TYPE_ORDER.map(type => ({
|
|
type,
|
|
label: ACCOUNT_TYPE_LABELS[type],
|
|
accounts: accounts
|
|
.filter(a => a.type === type)
|
|
.sort((a, b) => a.order - b.order),
|
|
}));
|
|
}, [accounts]);
|
|
|
|
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
|
|
|
|
const ledgerLines = useMemo(() => {
|
|
if (!selectedAccount) return [];
|
|
const isBsAccount = selectedAccount.type === 'asset' || selectedAccount.type === 'liability' || selectedAccount.type === 'equity';
|
|
// B/S科目かつ月フィルター時は「対象月より前の全仕訳による残高」を開始値とする
|
|
// P/L科目(収益・費用)は月次でリセットされるので期首残高0から始める
|
|
const startBalance = (isBsAccount && effectiveMonth)
|
|
? calcAccountBalance(
|
|
selectedAccount,
|
|
transactions.filter(tx => tx.date < `${effectiveMonth}-01`),
|
|
openingBalances,
|
|
)
|
|
: (isBsAccount ? (openingBalances.find(b => b.accountId === selectedAccountId)?.amount ?? 0) : 0);
|
|
return buildLedger(selectedAccount, accounts, targetTxs, startBalance);
|
|
}, [selectedAccount, accounts, targetTxs, openingBalances, selectedAccountId, effectiveMonth, transactions]);
|
|
|
|
// 前月繰越残高(B/S科目かつ月次表示時のみ)
|
|
const carryOverBalance = useMemo(() => {
|
|
if (!selectedAccount || !effectiveMonth) return 0;
|
|
const isBsAccount = selectedAccount.type === 'asset' || selectedAccount.type === 'liability' || selectedAccount.type === 'equity';
|
|
if (!isBsAccount) return 0;
|
|
return calcAccountBalance(
|
|
selectedAccount,
|
|
transactions.filter(tx => tx.date < `${effectiveMonth}-01`),
|
|
openingBalances,
|
|
);
|
|
}, [selectedAccount, effectiveMonth, transactions, openingBalances]);
|
|
|
|
// サマリ(借方合計・貸方合計・残高)
|
|
const summary = useMemo(() => {
|
|
const debitTotal = ledgerLines.reduce((s, l) => s + l.debit, 0);
|
|
const creditTotal = ledgerLines.reduce((s, l) => s + l.credit, 0);
|
|
const balance = ledgerLines.length > 0 ? ledgerLines[ledgerLines.length - 1].balance : carryOverBalance;
|
|
return { debitTotal, creditTotal, balance };
|
|
}, [ledgerLines, carryOverBalance]);
|
|
|
|
if (transactions.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-slate-500">
|
|
<p className="text-5xl mb-4">📗</p>
|
|
<p className="text-slate-400">データがありません。CSVをインポートしてください。</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-0 h-full overflow-hidden">
|
|
{/* 左: 勘定科目一覧 */}
|
|
<div className="w-52 shrink-0 overflow-y-auto border-r border-slate-700 p-4">
|
|
{previousPage && (
|
|
<button
|
|
onClick={goBack}
|
|
className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 mb-3"
|
|
>
|
|
← 戻る
|
|
</button>
|
|
)}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="text-sm font-semibold text-slate-400">勘定科目</h3>
|
|
<select
|
|
value={effectiveMonth}
|
|
onChange={e => setSelectedMonth(e.target.value)}
|
|
className="border rounded px-1 py-0.5 text-xs"
|
|
>
|
|
<option value="">全期間</option>
|
|
{availableMonths.map(m => (
|
|
<option key={m} value={m}>{m.replace('-', '年')}月</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-1.5 px-1 mb-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={hideInactiveLedgerAccounts}
|
|
onChange={e => setHideInactiveLedgerAccounts(e.target.checked)}
|
|
className="accent-indigo-500 w-3 h-3"
|
|
/>
|
|
<span className="text-xs text-slate-500">取引なし科目を非表示</span>
|
|
</label>
|
|
|
|
<div className="space-y-3">
|
|
{accountGroups.map(group => {
|
|
const visibleAccounts = group.accounts.filter(acc => {
|
|
const hasActivity = targetTxs.some(tx =>
|
|
tx.entries.some(e => e.accountId === acc.id)
|
|
);
|
|
return !hideInactiveLedgerAccounts || hasActivity;
|
|
});
|
|
if (visibleAccounts.length === 0) return null;
|
|
return (
|
|
<div key={group.type}>
|
|
<p className="text-xs font-bold text-slate-500 uppercase px-1 mb-1">{group.label}</p>
|
|
{visibleAccounts.map(acc => {
|
|
const hasActivity = targetTxs.some(tx =>
|
|
tx.entries.some(e => e.accountId === acc.id)
|
|
);
|
|
return (
|
|
<button
|
|
key={acc.id}
|
|
onClick={() => setSelectedAccountId(acc.id)}
|
|
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
|
|
selectedAccountId === acc.id
|
|
? 'bg-indigo-700 text-white font-semibold'
|
|
: hasActivity
|
|
? 'text-slate-300 hover:bg-slate-700'
|
|
: 'text-slate-600'
|
|
}`}
|
|
>
|
|
{acc.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 右: 元帳明細 */}
|
|
<div className="flex-1 overflow-hidden flex flex-col p-6 gap-4">
|
|
{selectedAccount ? (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-slate-100">{selectedAccount.name}</h2>
|
|
<p className="text-xs text-slate-500">
|
|
{ACCOUNT_TYPE_LABELS[selectedAccount.type]} / 正規残高: {selectedAccount.normalBalance === 'debit' ? '借方' : '貸方'}
|
|
</p>
|
|
</div>
|
|
{/* サマリ */}
|
|
<div className="flex gap-4 text-sm">
|
|
<div className="text-center">
|
|
<p className="text-xs text-slate-500">借方合計</p>
|
|
<p className="font-semibold text-indigo-400">{summary.debitTotal.toLocaleString()}</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-xs text-slate-500">貸方合計</p>
|
|
<p className="font-semibold text-rose-400">{summary.creditTotal.toLocaleString()}</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-xs text-slate-500">残高</p>
|
|
<p className={`font-bold ${summary.balance >= 0 ? 'text-slate-100' : 'text-rose-400'}`}>
|
|
{formatAmount(summary.balance)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-0 overflow-auto flex-1">
|
|
<table className="w-full">
|
|
<thead className="bg-slate-900 border-b border-slate-700 sticky top-0">
|
|
<tr>
|
|
<th className="table-th">日付</th>
|
|
<th className="table-th">摘要</th>
|
|
<th className="table-th">相手勘定</th>
|
|
<th className="table-th text-right">借方</th>
|
|
<th className="table-th text-right">貸方</th>
|
|
<th className="table-th text-right">残高</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-700/50">
|
|
{/* 前月繰越行(月次表示かつ繰越残高あり) */}
|
|
{effectiveMonth && carryOverBalance !== 0 && (
|
|
<tr className="bg-slate-800/60 border-b border-slate-600">
|
|
<td className="table-td text-slate-500 whitespace-nowrap">—</td>
|
|
<td className="table-td text-slate-500 italic">前月繰越</td>
|
|
<td className="table-td" />
|
|
<td className="table-td" />
|
|
<td className="table-td" />
|
|
<td className={`table-td text-right font-semibold ${carryOverBalance >= 0 ? 'text-slate-400' : 'text-rose-400'}`}>
|
|
{carryOverBalance.toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{ledgerLines.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="text-center py-8 text-slate-500 text-sm">
|
|
この期間の取引はありません
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
ledgerLines.map((line, i) => (
|
|
<tr key={i} className="hover:bg-slate-700/30">
|
|
<td className="table-td text-slate-400 whitespace-nowrap">{line.date}</td>
|
|
<td className="table-td max-w-xs truncate">{line.description}</td>
|
|
<td className="table-td text-xs text-slate-500">{line.counterAccountName}</td>
|
|
<td className="table-td text-right font-medium text-indigo-400">
|
|
{line.debit > 0 ? line.debit.toLocaleString() : ''}
|
|
</td>
|
|
<td className="table-td text-right font-medium text-rose-400">
|
|
{line.credit > 0 ? line.credit.toLocaleString() : ''}
|
|
</td>
|
|
<td className={`table-td text-right font-semibold ${
|
|
line.balance >= 0 ? 'text-slate-200' : 'text-rose-400'
|
|
}`}>
|
|
{line.balance.toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-slate-500">
|
|
<p>左から勘定科目を選択してください</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|