初回コミット
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user