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