初回コミット

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