+
{children}
diff --git a/src/data/categories.ts b/src/data/categories.ts
index fd5aee8..d140bf6 100644
--- a/src/data/categories.ts
+++ b/src/data/categories.ts
@@ -7,15 +7,27 @@ export interface Category {
}
export const CATEGORIES: Category[] = [
- { key: 'cash', label: '現金・預金', color: '#3B82F6', bgColor: 'bg-blue-100', textColor: 'text-blue-700' },
- { key: 'jp_stock', label: '国内株式', color: '#10B981', bgColor: 'bg-emerald-100', textColor: 'text-emerald-700' },
- { key: 'foreign_stock', label: '外国株式', color: '#6366F1', bgColor: 'bg-indigo-100', textColor: 'text-indigo-700' },
- { key: 'fund', label: '投資信託', color: '#F59E0B', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
- { key: 'crypto', label: '暗号資産', color: '#F97316', bgColor: 'bg-orange-100', textColor: 'text-orange-700' },
- { key: 'real_estate', label: '不動産', color: '#8B5CF6', bgColor: 'bg-violet-100', textColor: 'text-violet-700' },
- { key: 'insurance', label: '保険', color: '#EC4899', bgColor: 'bg-pink-100', textColor: 'text-pink-700' },
- { key: 'other', label: 'その他', color: '#6B7280', bgColor: 'bg-gray-100', textColor: 'text-gray-700' },
+ { key: 'cash', label: '現金・預金', color: '#2979FF', bgColor: 'bg-blue-100', textColor: 'text-blue-700' },
+ { key: 'stock', label: '株式', color: '#E53935', bgColor: 'bg-red-100', textColor: 'text-red-700' },
+ { key: 'fund', label: '投資信託・ロボアド', color: '#FFB300', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
+ { key: 'crypto', label: '暗号資産', color: '#8E24AA', bgColor: 'bg-purple-100', textColor: 'text-purple-700' },
+ { key: 'gold', label: '金・貴金属', color: '#FF6D00', bgColor: 'bg-orange-100', textColor: 'text-orange-700' },
+ { key: 'pension', label: '年金・確定拠出年金(iDeCo)', color: '#00ACC1', bgColor: 'bg-cyan-100', textColor: 'text-cyan-700' },
+ { key: 'other', label: 'その他', color: '#9E9E9E', bgColor: 'bg-gray-100', textColor: 'text-gray-600' },
];
-export const getCategoryByKey = (key: string): Category =>
- CATEGORIES.find(c => c.key === key) ?? CATEGORIES[CATEGORIES.length - 1];
+// 旧キーを新キーにマッピング(localStorage の既存データ互換)
+const KEY_ALIASES: Record
= {
+ jp_stock: 'stock',
+ foreign_stock: 'stock',
+ robo_advisor: 'fund',
+ real_estate: 'other',
+ insurance: 'other',
+};
+
+export const normalizeKey = (key: string): string => KEY_ALIASES[key] ?? key;
+
+export const getCategoryByKey = (key: string): Category => {
+ const normalized = normalizeKey(key);
+ return CATEGORIES.find(c => c.key === normalized) ?? CATEGORIES[CATEGORIES.length - 1];
+};
diff --git a/src/data/sampleData.ts b/src/data/sampleData.ts
index d5815a8..939cfaa 100644
--- a/src/data/sampleData.ts
+++ b/src/data/sampleData.ts
@@ -5,13 +5,11 @@ const now = new Date().toISOString();
export const sampleAssets: Asset[] = [
{ id: '1', name: '普通預金 三菱UFJ', category: 'cash', amount: 500000, memo: '', updatedAt: now },
{ id: '2', name: '定期預金 ゆうちょ', category: 'cash', amount: 1000000, memo: '', updatedAt: now },
- { id: '3', name: 'トヨタ株', category: 'jp_stock', amount: 300000, memo: '', updatedAt: now },
- { id: '4', name: 'S&P500 ETF', category: 'foreign_stock', amount: 450000, memo: '', updatedAt: now },
+ { id: '3', name: 'トヨタ株', category: 'stock', amount: 300000, memo: '', updatedAt: now },
+ { id: '4', name: 'S&P500 ETF', category: 'stock', amount: 450000, memo: '', updatedAt: now },
{ id: '5', name: 'eMAXIS Slim 全世界株', category: 'fund', amount: 200000, memo: '', updatedAt: now },
{ id: '6', name: 'Bitcoin', category: 'crypto', amount: 2100000, quantity: 0.15, unitPrice: 14000000, memo: 'BTC', updatedAt: now },
{ id: '7', name: 'Ethereum', category: 'crypto', amount: 1000000, quantity: 2, unitPrice: 500000, memo: 'ETH', updatedAt: now },
- { id: '8', name: 'マンション評価額', category: 'real_estate', amount: 25000000, memo: '', updatedAt: now },
- { id: '9', name: '生命保険 解約返戻金', category: 'insurance', amount: 800000, memo: '', updatedAt: now },
];
const totalNow = sampleAssets.reduce((s, a) => s + a.amount, 0);
diff --git a/src/hooks/useAssets.ts b/src/hooks/useAssets.ts
index 8f28768..5421b31 100644
--- a/src/hooks/useAssets.ts
+++ b/src/hooks/useAssets.ts
@@ -1,17 +1,27 @@
import { useState, useEffect } from 'react';
import { type Asset } from '../types';
import { sampleAssets } from '../data/sampleData';
+import { normalizeKey } from '../data/categories';
const KEY = 'assets';
+function migrate(assets: Asset[]): Asset[] {
+ return assets.map(a => ({ ...a, category: normalizeKey(a.category) }));
+}
+
function load(): Asset[] {
try {
const raw = localStorage.getItem(KEY);
- if (raw) return JSON.parse(raw) as Asset[];
+ if (raw) {
+ const parsed = JSON.parse(raw) as Asset[];
+ const migrated = migrate(parsed);
+ // 書き戻して次回以降は正規化済みで読める
+ localStorage.setItem(KEY, JSON.stringify(migrated));
+ return migrated;
+ }
} catch {
// ignore
}
- // First time: load sample data
localStorage.setItem(KEY, JSON.stringify(sampleAssets));
return sampleAssets;
}
@@ -49,5 +59,17 @@ export function useAssets() {
setAssets(prev => prev.filter(a => a.id !== id));
}
- return { assets, addAsset, updateAsset, deleteAsset };
+ function reorderAssets(fromId: string, toId: string) {
+ setAssets(prev => {
+ const list = [...prev];
+ const from = list.findIndex(a => a.id === fromId);
+ const to = list.findIndex(a => a.id === toId);
+ if (from === -1 || to === -1 || from === to) return prev;
+ const [item] = list.splice(from, 1);
+ list.splice(to, 0, item);
+ return list;
+ });
+ }
+
+ return { assets, addAsset, updateAsset, deleteAsset, reorderAssets };
}
diff --git a/src/hooks/useSnapshots.ts b/src/hooks/useSnapshots.ts
index eeabeb3..c64f77e 100644
--- a/src/hooks/useSnapshots.ts
+++ b/src/hooks/useSnapshots.ts
@@ -1,13 +1,28 @@
import { useState, useEffect } from 'react';
import { type Snapshot, type Asset } from '../types';
import { sampleSnapshots } from '../data/sampleData';
+import { normalizeKey } from '../data/categories';
const KEY = 'snapshots';
+function migrateSnapshot(snap: Snapshot): Snapshot {
+ const byCategory: Record = {};
+ for (const [k, v] of Object.entries(snap.byCategory)) {
+ const key = normalizeKey(k);
+ byCategory[key] = (byCategory[key] ?? 0) + v;
+ }
+ return { ...snap, byCategory };
+}
+
function load(): Snapshot[] {
try {
const raw = localStorage.getItem(KEY);
- if (raw) return JSON.parse(raw) as Snapshot[];
+ if (raw) {
+ const parsed = JSON.parse(raw) as Snapshot[];
+ const migrated = parsed.map(migrateSnapshot);
+ localStorage.setItem(KEY, JSON.stringify(migrated));
+ return migrated;
+ }
} catch {
// ignore
}
diff --git a/src/index.css b/src/index.css
index 2928806..1a7c4ac 100644
--- a/src/index.css
+++ b/src/index.css
@@ -10,3 +10,11 @@ body {
#root {
min-height: 100vh;
}
+
+/* Remove focus outline on recharts SVG elements */
+.recharts-sector:focus,
+.recharts-surface:focus,
+.recharts-wrapper svg:focus,
+.recharts-pie-sector path:focus {
+ outline: none;
+}
diff --git a/src/pages/Assets.tsx b/src/pages/Assets.tsx
index 04097fa..0cbf49f 100644
--- a/src/pages/Assets.tsx
+++ b/src/pages/Assets.tsx
@@ -1,13 +1,8 @@
-import { useState } from 'react';
+import { useState, useRef } from 'react';
import { useAssets } from '../hooks/useAssets';
import { CATEGORIES } from '../data/categories';
import type { Asset } from '../types';
-
-function formatJPY(amount: number): string {
- if (amount >= 100000000) return `${(amount / 100000000).toFixed(2)}億円`;
- if (amount >= 10000) return `${Math.floor(amount / 10000).toLocaleString()}万円`;
- return `${amount.toLocaleString()}円`;
-}
+import { formatJPY } from '../utils/format';
const EMPTY_FORM = {
name: '',
@@ -19,10 +14,18 @@ const EMPTY_FORM = {
};
export function Assets() {
- const { assets, addAsset, updateAsset, deleteAsset } = useAssets();
+ const { assets, addAsset, updateAsset, deleteAsset, reorderAssets } = useAssets();
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
+ const [dragOverId, setDragOverId] = useState(null);
+ const [touchDraggingId, setTouchDraggingId] = useState(null);
+ const [touchOverIdState, setTouchOverIdState] = useState(null);
+ const dragId = useRef(null);
+ const touchDragId = useRef(null);
+ const touchOverId = useRef(null);
+ const touchStartY = useRef(0);
+ const touchDragging = useRef(false);
const isCrypto = form.category === 'crypto';
@@ -81,6 +84,52 @@ export function Assets() {
if (confirm('この資産を削除しますか?')) deleteAsset(id);
}
+ // Mouse drag handlers
+ function handleDragStart(id: string) { dragId.current = id; }
+ function handleDragOver(e: React.DragEvent, id: string) { e.preventDefault(); setDragOverId(id); }
+ function handleDrop(toId: string) {
+ if (dragId.current) reorderAssets(dragId.current, toId);
+ dragId.current = null;
+ setDragOverId(null);
+ }
+ function handleDragEnd() { dragId.current = null; setDragOverId(null); }
+
+ // Touch drag handlers
+ function handleTouchStart(e: React.TouchEvent, id: string) {
+ touchDragId.current = id;
+ touchDragging.current = false;
+ touchStartY.current = e.touches[0].clientY;
+ }
+
+ function handleTouchMove(e: React.TouchEvent) {
+ const dy = Math.abs(e.touches[0].clientY - touchStartY.current);
+ if (!touchDragging.current) {
+ if (dy < 10) return; // まだ動いていない、スクロールを妨げない
+ touchDragging.current = true;
+ setTouchDraggingId(touchDragId.current);
+ }
+ e.preventDefault();
+ const touch = e.touches[0];
+ const el = document.elementFromPoint(touch.clientX, touch.clientY);
+ const li = el?.closest('[data-asset-id]') as HTMLElement | null;
+ const overId = li?.dataset.assetId ?? null;
+ if (overId !== touchOverId.current) {
+ touchOverId.current = overId;
+ setTouchOverIdState(overId !== touchDragId.current ? overId : null);
+ }
+ }
+
+ function handleTouchEnd() {
+ if (touchDragging.current && touchDragId.current && touchOverId.current && touchDragId.current !== touchOverId.current) {
+ reorderAssets(touchDragId.current, touchOverId.current);
+ }
+ touchDragId.current = null;
+ touchOverId.current = null;
+ touchDragging.current = false;
+ setTouchDraggingId(null);
+ setTouchOverIdState(null);
+ }
+
// Group assets by category
const grouped = CATEGORIES.map(cat => ({
cat,
@@ -121,7 +170,26 @@ export function Assets() {