初回コミット

This commit is contained in:
2026-04-03 21:28:33 +09:00
commit 9a5086187b
47 changed files with 16941 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
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>
);
}