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('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(null); // データ内に登場する全カテゴリを抽出(マッピング未設定のものを強調) const allCategories = useMemo(() => { const cats = new Map>(); 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) => { 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 (

設定・マッピング

{saved && ( ✅ 保存しました )}
{/* タブ */}
{([['category', 'カテゴリ → 勘定科目'], ['institution', '金融機関 → 資産勘定'], ['backup', 'バックアップ']] as [Tab, string][]).map( ([t, label]) => ( ) )}
{tab === 'category' && (

MoneyForwardの大項目・中項目を費用/収益の勘定科目に対応付けます。

{/* 新規追加フォーム */}

マッピングを追加・上書き

setEditCategory(e.target.value)} list="category-list" className="border rounded px-2 py-1.5 text-sm w-36" placeholder="食費" /> {[...allCategories.keys()].map(c => (
setEditSubCategory(e.target.value)} list="subcategory-list" className="border rounded px-2 py-1.5 text-sm w-36" placeholder="外食(任意)" /> {editCategory && allCategories.get(editCategory) ? [...allCategories.get(editCategory)!].map(s => (
{/* データ内カテゴリ一覧 */} {allCategories.size > 0 && (

データ内のカテゴリ(クリックで編集欄へ)

{[...allCategories.entries()].map(([cat, subs]) => { const subList = ['', ...[...subs]]; return subList.map(sub => { const mapped = findCurrentMapping(cat, sub); return (
{ setEditCategory(cat); setEditSubCategory(sub); }} className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700 cursor-pointer" >
{cat} {sub && ( <> / {sub} )} {!sub && (大項目全体)}
{mapped ?? '未設定'}
); }); })}
)} {/* 現在のマッピング一覧 */}

現在のマッピング(全件)

{categoryMappings.map((m, i) => ( { setEditCategory(m.mfCategory); setEditSubCategory(m.mfSubCategory); setEditAccountId(m.accountId); }} > ))}
大項目 中項目 勘定科目
{m.mfCategory} {m.mfSubCategory || '(全体)'} {m.accountId === '_skip_' ? 'スキップ(資産間移動)' : accounts.find(a => a.id === m.accountId)?.name ?? m.accountId}
)} {tab === 'backup' && (

全データをJSONファイルに保存・復元します。ブラウザのデータが消えた場合の備えとして定期的にエクスポートしてください。

{/* エクスポート */}

エクスポート(バックアップ作成)

仕訳件数: {transactions.length.toLocaleString()} 件
勘定科目: {accounts.length} 件
カテゴリマッピング: {categoryMappings.length} 件
{/* インポート(復元) */}

インポート(バックアップから復元)

現在のデータはすべて上書きされます。復元前に必ずエクスポートで保存してください。

{restoreError && (

エラー: {restoreError}

)} {restoreSuccess && (

復元しました

)}
)} {tab === 'institution' && (

MoneyForwardの保有金融機関を資産勘定科目に対応付けます。

{/* 新規追加フォーム */}

マッピングを追加・上書き

setEditInstitution(e.target.value)} list="institution-list" className="border rounded px-2 py-1.5 text-sm w-52" placeholder="SBI銀行" /> {allInstitutions.map(inst => (
{/* データ内金融機関 */} {allInstitutions.length > 0 && (

データ内の金融機関

{allInstitutions.map(inst => { const m = institutionMappings.find(im => im.institution === inst); const accName = m ? accounts.find(a => a.id === m.accountId)?.name : null; return (
setEditInstitution(inst)} className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700 cursor-pointer" > {inst} {accName ?? '未設定'}
); })}
)} {/* 現在のマッピング */}

現在のマッピング

{institutionMappings.map((m, i) => ( ))}
保有金融機関 勘定科目
{m.institution} {accounts.find(a => a.id === m.accountId)?.name ?? m.accountId}
)}
); }