Files
kakeibo/src/components/SettingsPage.tsx
T
2026-04-03 21:28:33 +09:00

484 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useRef } from 'react';
import { useStore } from '../store';
import type { BackupData } from '../store';
import { ACCOUNT_TYPE_LABELS } from '../utils/accounts';
type Tab = 'category' | 'institution' | 'backup';
export default function SettingsPage() {
const {
accounts, categoryMappings, institutionMappings,
upsertCategoryMapping, upsertInstitutionMapping,
mergeDefaultMappings, mergeDefaultAccounts,
transactions, openingBalances, importedIds, rawRecords, transferOverrides,
restoreFromBackup,
} = useStore();
const [tab, setTab] = useState<Tab>('category');
const [editCategory, setEditCategory] = useState('');
const [editSubCategory, setEditSubCategory] = useState('');
const [editAccountId, setEditAccountId] = useState('');
const [editInstitution, setEditInstitution] = useState('');
const [editInstAccountId, setEditInstAccountId] = useState('');
const [saved, setSaved] = useState(false);
const [restoreError, setRestoreError] = useState('');
const [restoreSuccess, setRestoreSuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// データ内に登場する全カテゴリを抽出(マッピング未設定のものを強調)
const allCategories = useMemo(() => {
const cats = new Map<string, Set<string>>();
for (const tx of transactions) {
if (!tx.mfCategory) continue;
if (!cats.has(tx.mfCategory)) cats.set(tx.mfCategory, new Set());
if (tx.mfSubCategory) cats.get(tx.mfCategory)!.add(tx.mfSubCategory);
}
return cats;
}, [transactions]);
const allInstitutions = useMemo(() => {
return [...new Set(transactions.map(tx => tx.mfInstitution ?? '').filter(Boolean))];
}, [transactions]);
const expenseIncomeAccounts = accounts.filter(
a => a.type === 'expense' || a.type === 'income'
);
const flash = () => {
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
const handleExport = () => {
const backup: BackupData = {
version: 1,
exportedAt: new Date().toISOString(),
accounts,
transactions,
categoryMappings,
institutionMappings,
openingBalances,
importedIds,
rawRecords,
transferOverrides,
};
const json = JSON.stringify(backup, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `household-backup-${dateStr}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
setRestoreError('');
setRestoreSuccess(false);
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target?.result as string) as BackupData;
if (data.version !== 1) throw new Error('バージョンが異なります');
if (!Array.isArray(data.transactions)) throw new Error('データ形式が不正です');
if (!window.confirm(
`このバックアップを復元しますか?\n` +
`日時: ${new Date(data.exportedAt).toLocaleString()}\n` +
`仕訳件数: ${data.transactions.length}\n\n` +
`現在のデータはすべて上書きされます。`
)) return;
restoreFromBackup(data);
setRestoreSuccess(true);
setTimeout(() => setRestoreSuccess(false), 3000);
} catch (err) {
setRestoreError(err instanceof Error ? err.message : '読み込みに失敗しました');
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
reader.readAsText(file, 'utf-8');
};
const handleSaveCategory = () => {
if (!editCategory || !editAccountId) return;
upsertCategoryMapping({
mfCategory: editCategory,
mfSubCategory: editSubCategory,
accountId: editAccountId,
});
setEditCategory('');
setEditSubCategory('');
setEditAccountId('');
flash();
};
const handleSaveInstitution = () => {
if (!editInstitution || !editInstAccountId) return;
upsertInstitutionMapping({
institution: editInstitution,
accountId: editInstAccountId,
});
setEditInstitution('');
setEditInstAccountId('');
flash();
};
const findCurrentMapping = (cat: string, sub: string) => {
const m = categoryMappings.find(
m => m.mfCategory === cat && m.mfSubCategory === sub
) ?? categoryMappings.find(
m => m.mfCategory === cat && m.mfSubCategory === ''
);
if (!m) return null;
if (m.accountId === '_skip_') return 'スキップ(資産間移動)';
return accounts.find(a => a.id === m.accountId)?.name ?? m.accountId;
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-100"></h2>
<div className="flex items-center gap-3">
{saved && (
<span className="text-sm text-emerald-400 font-medium"> </span>
)}
<button
onClick={() => {
mergeDefaultAccounts();
mergeDefaultMappings();
flash();
}}
className="px-3 py-1.5 text-sm bg-indigo-900/50 text-indigo-300 border border-indigo-700 rounded-md hover:bg-indigo-800"
>
</button>
</div>
</div>
{/* タブ */}
<div className="flex gap-1 border-b border-slate-700">
{([['category', 'カテゴリ → 勘定科目'], ['institution', '金融機関 → 資産勘定'], ['backup', 'バックアップ']] as [Tab, string][]).map(
([t, label]) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t
? 'border-indigo-500 text-indigo-400'
: 'border-transparent text-slate-500 hover:text-slate-300'
}`}
>
{label}
</button>
)
)}
</div>
{tab === 'category' && (
<div className="space-y-4">
<p className="text-sm text-slate-400">
MoneyForwardの大項目/
</p>
{/* 新規追加フォーム */}
<div className="card">
<h3 className="text-sm font-semibold text-slate-400 mb-3"></h3>
<div className="flex gap-2 items-end flex-wrap">
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input
type="text"
value={editCategory}
onChange={e => setEditCategory(e.target.value)}
list="category-list"
className="border rounded px-2 py-1.5 text-sm w-36"
placeholder="食費"
/>
<datalist id="category-list">
{[...allCategories.keys()].map(c => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">=</label>
<input
type="text"
value={editSubCategory}
onChange={e => setEditSubCategory(e.target.value)}
list="subcategory-list"
className="border rounded px-2 py-1.5 text-sm w-36"
placeholder="外食(任意)"
/>
<datalist id="subcategory-list">
{editCategory && allCategories.get(editCategory)
? [...allCategories.get(editCategory)!].map(s => (
<option key={s} value={s} />
))
: null}
</datalist>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<select
value={editAccountId}
onChange={e => setEditAccountId(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-44"
>
<option value=""></option>
<option value="_skip_"> </option>
{(['income', 'expense'] as const).map(type => (
<optgroup key={type} label={ACCOUNT_TYPE_LABELS[type]}>
{expenseIncomeAccounts
.filter(a => a.type === type)
.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</optgroup>
))}
</select>
</div>
<button
onClick={handleSaveCategory}
disabled={!editCategory || !editAccountId}
className="px-4 py-1.5 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700 disabled:opacity-40"
>
</button>
</div>
</div>
{/* データ内カテゴリ一覧 */}
{allCategories.size > 0 && (
<div className="card">
<h3 className="text-sm font-semibold text-slate-400 mb-3">
</h3>
<div className="space-y-2">
{[...allCategories.entries()].map(([cat, subs]) => {
const subList = ['', ...[...subs]];
return subList.map(sub => {
const mapped = findCurrentMapping(cat, sub);
return (
<div
key={`${cat}-${sub}`}
onClick={() => { setEditCategory(cat); setEditSubCategory(sub); }}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700 cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-300">{cat}</span>
{sub && (
<>
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">{sub}</span>
</>
)}
{!sub && <span className="text-xs text-slate-600"></span>}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
mapped ? 'bg-emerald-900/50 text-emerald-400' : 'bg-amber-900/50 text-amber-400'
}`}>
{mapped ?? '未設定'}
</span>
</div>
);
});
})}
</div>
</div>
)}
{/* 現在のマッピング一覧 */}
<div className="card">
<h3 className="text-sm font-semibold text-slate-400 mb-3"></h3>
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-slate-800">
<tr className="border-b border-slate-700">
<th className="table-th"></th>
<th className="table-th"></th>
<th className="table-th"></th>
</tr>
</thead>
<tbody>
{categoryMappings.map((m, i) => (
<tr
key={i}
className="border-b border-slate-700/50 hover:bg-slate-700 cursor-pointer"
onClick={() => {
setEditCategory(m.mfCategory);
setEditSubCategory(m.mfSubCategory);
setEditAccountId(m.accountId);
}}
>
<td className="table-td">{m.mfCategory}</td>
<td className="table-td text-slate-500">{m.mfSubCategory || '(全体)'}</td>
<td className={`table-td ${m.accountId === '_skip_' ? 'text-slate-500 italic' : 'text-indigo-400'}`}>
{m.accountId === '_skip_'
? 'スキップ(資産間移動)'
: accounts.find(a => a.id === m.accountId)?.name ?? m.accountId}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{tab === 'backup' && (
<div className="space-y-4">
<p className="text-sm text-slate-400">
JSONファイルに保存
</p>
{/* エクスポート */}
<div className="card space-y-3">
<h3 className="text-sm font-semibold text-slate-300"></h3>
<div className="text-sm text-slate-400 space-y-1">
<div>: <span className="text-slate-200">{transactions.length.toLocaleString()} </span></div>
<div>: <span className="text-slate-200">{accounts.length} </span></div>
<div>: <span className="text-slate-200">{categoryMappings.length} </span></div>
</div>
<button
onClick={handleExport}
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700"
>
JSONをダウンロード
</button>
</div>
{/* インポート(復元) */}
<div className="card space-y-3">
<h3 className="text-sm font-semibold text-slate-300"></h3>
<p className="text-xs text-amber-400">
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="block text-sm text-slate-400 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:bg-slate-700 file:text-slate-200 hover:file:bg-slate-600 cursor-pointer"
/>
{restoreError && (
<p className="text-sm text-red-400">: {restoreError}</p>
)}
{restoreSuccess && (
<p className="text-sm text-emerald-400"></p>
)}
</div>
</div>
)}
{tab === 'institution' && (
<div className="space-y-4">
<p className="text-sm text-slate-400">
MoneyForwardの保有金融機関を資産勘定科目に対応付けます
</p>
{/* 新規追加フォーム */}
<div className="card">
<h3 className="text-sm font-semibold text-slate-400 mb-3"></h3>
<div className="flex gap-2 items-end">
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input
type="text"
value={editInstitution}
onChange={e => setEditInstitution(e.target.value)}
list="institution-list"
className="border rounded px-2 py-1.5 text-sm w-52"
placeholder="SBI銀行"
/>
<datalist id="institution-list">
{allInstitutions.map(inst => (
<option key={inst} value={inst} />
))}
</datalist>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<select
value={editInstAccountId}
onChange={e => setEditInstAccountId(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-44"
>
<option value=""></option>
{(['asset', 'liability'] as const).map(type => (
<optgroup key={type} label={ACCOUNT_TYPE_LABELS[type]}>
{accounts.filter(a => a.type === type).map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</optgroup>
))}
</select>
</div>
<button
onClick={handleSaveInstitution}
disabled={!editInstitution || !editInstAccountId}
className="px-4 py-1.5 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700 disabled:opacity-40"
>
</button>
</div>
</div>
{/* データ内金融機関 */}
{allInstitutions.length > 0 && (
<div className="card">
<h3 className="text-sm font-semibold text-slate-400 mb-3"></h3>
<div className="space-y-1">
{allInstitutions.map(inst => {
const m = institutionMappings.find(im => im.institution === inst);
const accName = m ? accounts.find(a => a.id === m.accountId)?.name : null;
return (
<div
key={inst}
onClick={() => setEditInstitution(inst)}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700 cursor-pointer"
>
<span className="text-sm text-slate-300">{inst}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
accName ? 'bg-emerald-900/50 text-emerald-400' : 'bg-amber-900/50 text-amber-400'
}`}>
{accName ?? '未設定'}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 現在のマッピング */}
<div className="card">
<h3 className="text-sm font-semibold text-slate-400 mb-3"></h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="table-th"></th>
<th className="table-th"></th>
</tr>
</thead>
<tbody>
{institutionMappings.map((m, i) => (
<tr key={i} className="border-b border-slate-700/50 hover:bg-slate-700">
<td className="table-td">{m.institution}</td>
<td className="table-td text-indigo-400">
{accounts.find(a => a.id === m.accountId)?.name ?? m.accountId}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}