484 lines
20 KiB
TypeScript
484 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|