diff --git a/.gitignore b/.gitignore index a547bf3..5bef997 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,6 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr +node_modules/ +dist/ +dist-ssr/ *.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea +.env .DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/README.md b/README.md index 7dbf7eb..45cf37d 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,47 @@ -# React + TypeScript + Vite +# 資産管理アプリ -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +マネーフォワードライクなブラウザ資産管理アプリ。**暗号資産・金などのオルタナティブ資産を伝統的資産と分けて管理**できることが特徴です。 -Currently, two official plugins are available: +🌐 **[asset-management-nu-seven.vercel.app](https://asset-management-nu-seven.vercel.app)** -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +## 主な機能 -## React Compiler +- **ダッシュボード** — 総資産・伝統的資産/オルタナティブ資産/運用資産合計の内訳・カテゴリ別ドーナツグラフ(割合表示) +- **資産一覧** — カテゴリ別グループ表示・追加/編集/削除・ドラッグ&ドロップで並び替え +- **履歴** — 月次スナップショットの記録・折れ線グラフで推移確認 +- **データ永続化** — localStorage(サーバー不要、ブラウザ内に保存) +- **レスポンシブ対応** — デスクトップはサイドバー、スマホはボトムナビ -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +## 資産カテゴリ -## Expanding the ESLint configuration +| カテゴリ | 分類 | 説明 | +|----------|------|------| +| 現金・預金 | — | 銀行口座、ゆうちょなど | +| 株式 | 伝統的資産 | 国内株・外国株・ETFなど | +| 投資信託・ロボアド | 伝統的資産 | インデックスファンド、ロボアドバイザーなど | +| 年金・確定拠出年金(iDeCo) | 伝統的資産 | 企業型DC・iDeCoなど | +| **暗号資産** | オルタナティブ | BTC・ETHなど(数量×単価で円換算) | +| 金・貴金属 | オルタナティブ | 金・プラチナなど | +| その他 | — | 上記に当てはまらないもの | -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +## 技術スタック -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +- [React](https://react.dev/) + [Vite](https://vite.dev/) + TypeScript +- [Tailwind CSS v3](https://tailwindcss.com/) +- [Recharts](https://recharts.org/)(グラフ) +- [React Router](https://reactrouter.com/) - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +## ローカル開発 - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +git clone https://github.com/ShibaInuChan/Asset-Management.git +cd Asset-Management +npm install +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +ブラウザで http://localhost:5173 を開く。 -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## デプロイ -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +mainブランチへのプッシュで Vercel が自動デプロイします。 diff --git a/index.html b/index.html index 7fe0608..51ab2f1 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - vite-template + 資産管理ダッシュボード
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c9d8103..6991caa 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -48,7 +48,7 @@ export function Layout({ children }: { children: ReactNode }) { {/* Main content */}
-
+
{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() {
    {items.map(asset => ( -
  • +
  • handleDragStart(asset.id)} + onDragOver={e => handleDragOver(e, asset.id)} + onDrop={() => handleDrop(asset.id)} + onDragEnd={handleDragEnd} + className={[ + 'flex items-center justify-between px-5 py-4 transition-colors', + dragOverId === asset.id || touchOverIdState === asset.id ? 'border-t-2 border-blue-400 bg-blue-50' : '', + touchDraggingId === asset.id ? 'opacity-40' : '', + ].join(' ')} + > +
    handleTouchStart(e, asset.id)} + onTouchMove={e => handleTouchMove(e)} + onTouchEnd={() => handleTouchEnd()} + >⠿

    {asset.name}

    {asset.quantity != null && asset.unitPrice != null && ( @@ -133,14 +201,8 @@ export function Assets() {

    {formatJPY(asset.amount)}

    - - + +
  • ))} @@ -153,7 +215,9 @@ export function Assets() { {showModal && (
    setShowModal(false)} /> -
    + {/* mb-16 = bottom nav height on mobile */} +

    {editing ? '資産を編集' : '資産を追加'}

    @@ -233,7 +297,7 @@ export function Assets() { className="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
    -
    +