├── CNAME ├── .gitignore ├── public └── CNAME ├── src ├── components │ ├── SidebarTabs.jsx │ ├── NewsView.jsx │ ├── NotesView.jsx │ ├── TutorialView.jsx │ ├── StatsView.css │ ├── CalendarView.css │ ├── TransactionForm.css │ ├── Navbar.css │ ├── TransactionsList.css │ ├── Navbar.jsx │ ├── CalendarView.jsx │ ├── TransactionForm.jsx │ ├── StatsView.jsx │ ├── Sidebar.jsx │ └── TransactionsList.jsx ├── App.css ├── main.jsx ├── index.css ├── firebase.js └── App.jsx ├── .DS_Store ├── background.jpeg ├── vite.config.js ├── vite-project ├── src │ ├── counter.ts │ ├── main.ts │ ├── typescript.svg │ └── style.css ├── package.json ├── .gitignore ├── index.html ├── tsconfig.json ├── public │ └── vite.svg └── package-lock.json ├── index.html ├── dist ├── assets │ └── index-7baddf09.css └── index.html ├── package.json ├── .github └── workflows │ └── deploy.yml └── README.md /CNAME: -------------------------------------------------------------------------------- 1 | finance.vietnq.space -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /public/CNAME: -------------------------------------------------------------------------------- 1 | finance.vietnq.space 2 | -------------------------------------------------------------------------------- /src/components/SidebarTabs.jsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/components/NewsView.jsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/NotesView.jsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/TutorialView.jsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | 4 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quocviethere/finance/main/.DS_Store -------------------------------------------------------------------------------- /background.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quocviethere/finance/main/background.jpeg -------------------------------------------------------------------------------- /src/components/StatsView.css: -------------------------------------------------------------------------------- 1 | .stats-view { 2 | margin-top: 1rem; 3 | } 4 | 5 | .stats-view h2 { 6 | margin: 1.2rem 0 0.6rem; 7 | } -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .app-container { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .content { 8 | flex: 1; 9 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | base: '/', // <-- Change this line! 7 | }); -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | 10 | ); -------------------------------------------------------------------------------- /vite-project/src/counter.ts: -------------------------------------------------------------------------------- 1 | export function setupCounter(element: HTMLButtonElement) { 2 | let counter = 0 3 | const setCounter = (count: number) => { 4 | counter = count 5 | element.innerHTML = `count is ${counter}` 6 | } 7 | element.addEventListener('click', () => setCounter(counter + 1)) 8 | setCounter(0) 9 | } 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Finance Tracker 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /vite-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-project", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "typescript": "~5.9.3", 13 | "vite": "^7.1.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vite-project/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /vite-project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vite-project 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /dist/assets/index-7baddf09.css: -------------------------------------------------------------------------------- 1 | @import"https://fonts.googleapis.com/css2?family=Manrope:wght@300..800&display=swap";*{box-sizing:border-box;margin:0;padding:0;font-family:Manrope,Helvetica,sans-serif}body{background:#f9fafb;color:#333;font-size:18px;line-height:1.6}button,input,select,textarea{font-size:1rem}button{cursor:pointer}.content{max-width:900px;margin:0 auto;padding:1rem}.recharts-legend-item-text{font-size:11px!important;line-height:1.2} 2 | -------------------------------------------------------------------------------- /src/components/CalendarView.css: -------------------------------------------------------------------------------- 1 | .calendar-view { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | } 6 | 7 | .day-summary { 8 | margin-top: 1rem; 9 | width: 100%; 10 | max-width: 600px; 11 | } 12 | 13 | .day-summary ul { 14 | margin-top: 0.5rem; 15 | list-style: none; 16 | padding-left: 0; 17 | } 18 | 19 | .day-summary li { 20 | padding: 0.3rem 0; 21 | border-bottom: 1px solid #e5e7eb; 22 | } -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Finance Tracker 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/TransactionForm.css: -------------------------------------------------------------------------------- 1 | .transaction-form { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.5rem; 5 | margin: 1rem 0; 6 | } 7 | 8 | .transaction-form input, 9 | .transaction-form select { 10 | padding: 0.4rem 0.6rem; 11 | border: 1px solid #d1d5db; 12 | border-radius: 4px; 13 | } 14 | 15 | .transaction-form button { 16 | background: #1abc9c; 17 | color: #fff; 18 | border: none; 19 | padding: 0.5rem 1rem; 20 | border-radius: 4px; 21 | } -------------------------------------------------------------------------------- /src/components/Navbar.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | padding: 1rem 2rem; 6 | background: #2c3e50; 7 | color: #fff; 8 | } 9 | 10 | .logo { 11 | font-size: 1.5rem; 12 | } 13 | 14 | .nav-buttons button { 15 | margin-left: 0.5rem; 16 | padding: 0.4rem 0.8rem; 17 | background: #34495e; 18 | color: #fff; 19 | border: none; 20 | border-radius: 4px; 21 | font-size: 1.2rem; 22 | } 23 | 24 | .nav-buttons button.active, 25 | .nav-buttons button:hover { 26 | background: #1abc9c; 27 | } -------------------------------------------------------------------------------- /src/components/TransactionsList.css: -------------------------------------------------------------------------------- 1 | .transactions-table { 2 | width: 100%; 3 | border-collapse: collapse; 4 | table-layout: fixed; 5 | } 6 | 7 | .transactions-table th, 8 | .transactions-table td { 9 | padding: 0.6rem; 10 | border-bottom: 1px solid #e5e7eb; 11 | text-align: left; 12 | } 13 | 14 | .transactions-table tr:nth-child(even) { 15 | background: #f3f4f6; 16 | } 17 | 18 | .transactions-table button { 19 | margin-right: 0.3rem; 20 | padding: 0.3rem 0.6rem; 21 | border: none; 22 | border-radius: 4px; 23 | background: transparent; 24 | color: #4b5563; 25 | font-size: 1rem; 26 | display: inline-flex; 27 | align-items: center; 28 | } 29 | 30 | .transactions-table button:hover { 31 | color: #1abc9c; 32 | } -------------------------------------------------------------------------------- /vite-project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "types": ["vite/client"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@300..800&display=swap'); 2 | * { 3 | box-sizing: border-box; 4 | margin: 0; 5 | padding: 0; 6 | font-family: Manrope, Helvetica, sans-serif; 7 | } 8 | 9 | body { 10 | background: #f9fafb; 11 | color: #333; 12 | font-size: 18px; /* increased base font size */ 13 | line-height: 1.6; /* improve readability */ 14 | } 15 | 16 | button, input, select, textarea { 17 | font-size: 1rem; /* inherit base size for controls */ 18 | } 19 | 20 | button { 21 | cursor: pointer; 22 | } 23 | 24 | .content { 25 | max-width: 900px; 26 | margin: 0 auto; 27 | padding: 1rem; 28 | } 29 | 30 | /* Make Recharts legend item text smaller */ 31 | .recharts-legend-item-text { 32 | font-size: 11px !important; 33 | line-height: 1.2; 34 | } -------------------------------------------------------------------------------- /vite-project/src/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import typescriptLogo from './typescript.svg' 3 | import viteLogo from '/vite.svg' 4 | import { setupCounter } from './counter.ts' 5 | 6 | document.querySelector('#app')!.innerHTML = ` 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |

Vite + TypeScript

15 |
16 | 17 |
18 |

19 | Click on the Vite and TypeScript logos to learn more 20 |

21 |
22 | ` 23 | 24 | setupCounter(document.querySelector('#counter')!) 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "finance-tracker", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "predeploy": "npm run build", 10 | "deploy": "gh-pages -d dist" 11 | }, 12 | "dependencies": { 13 | "chart.js": "^4.4.0", 14 | "date-fns": "^3.2.0", 15 | "firebase": "^10.8.0", 16 | "framer-motion": "^12.23.12", 17 | "lucide-react": "^0.537.0", 18 | "react": "^18.2.0", 19 | "react-calendar": "^6.0.0", 20 | "react-chartjs-2": "^5.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-icons": "^4.10.1", 23 | "react-router-dom": "^6.20.0", 24 | "recharts": "^3.1.2", 25 | "xlsx": "^0.18.5" 26 | }, 27 | "devDependencies": { 28 | "@vitejs/plugin-react": "^4.0.3", 29 | "gh-pages": "^6.3.0", 30 | "vite": "^4.5.2" 31 | }, 32 | "homepage": "https://finance.vietnq.space/" 33 | } 34 | -------------------------------------------------------------------------------- /src/firebase.js: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app'; 2 | import { getFirestore, enableIndexedDbPersistence } from 'firebase/firestore'; 3 | 4 | const firebaseConfig = { 5 | apiKey: "AIzaSyCiEQBicJPGNU0YPK9KlbJNDw0Vu9XbIa4", 6 | authDomain: "finance-tracker-81a65.firebaseapp.com", 7 | projectId: "finance-tracker-81a65", 8 | storageBucket: "finance-tracker-81a65.firebasestorage.app", 9 | messagingSenderId: "3056931749", 10 | appId: "1:3056931749:web:026e5cb70c4d57b4590e4c", 11 | measurementId: "G-MSHPXJ8VB8" 12 | }; 13 | 14 | const app = initializeApp(firebaseConfig); 15 | const db = getFirestore(app); 16 | 17 | // Enable offline persistence so data stays in IndexedDB and appears instantly after reload. 18 | enableIndexedDbPersistence(db).catch((err) => { 19 | // Ignore if persistence is already enabled in another tab or unsupported. 20 | if (err.code !== 'failed-precondition' && err.code !== 'unimplemented') { 21 | console.warn('Persistence error', err); 22 | } 23 | }); 24 | 25 | export { db }; -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: pages 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: npm 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Build 29 | run: npm run build 30 | - uses: actions/configure-pages@v5 31 | - uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: dist 34 | 35 | deploy: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | environment: 39 | name: github-pages 40 | url: ${{ steps.deployment.outputs.page_url }} 41 | steps: 42 | - id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Navbar.css'; 3 | import { FiList, FiPieChart, FiCalendar } from 'react-icons/fi'; 4 | // import Sidebar from './Sidebar'; // Placeholder for future sidebar 5 | 6 | function Navbar({ currentView, onChangeView }) { 7 | return ( 8 |
9 |

Finance Tracker

10 | 30 |
31 | ); 32 | } 33 | 34 | export default Navbar; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Finance Tracker 2 | 3 | A simple personal finance tracker built with React, Vite, Firebase (Cloud Firestore) and Chart.js. 4 | 5 | ## Features 6 | 7 | * Add, edit and delete transactions in Vietnamese đồng (VND) 8 | * Data stored in Firebase Cloud Firestore 9 | * Statistics view: 10 | * Pie chart by categories 11 | * Bar chart by month 12 | * Calendar view to see daily totals 13 | * Responsive UI 14 | 15 | ## Getting Started 16 | 17 | ### Prerequisites 18 | 19 | * Node.js ≥ 18 20 | * Firebase project (already configured in `src/firebase.js`) 21 | 22 | ### Installation 23 | 24 | ```bash 25 | # move into project root 26 | cd finance 27 | 28 | # install packages 29 | npm install 30 | 31 | # start development server 32 | npm run dev 33 | ``` 34 | 35 | The app will be available at http://localhost:5173. 36 | 37 | ### Build for production 38 | 39 | ```bash 40 | npm run build 41 | ``` 42 | 43 | This generates static files in `dist/`. 44 | 45 | ## Firebase Setup 46 | 47 | The `firebaseConfig` object is already populated. If you fork this template, create your own Firebase project and replace the keys in `src/firebase.js`. 48 | 49 | Make sure Cloud Firestore is enabled and create a collection named `transactions` with no further configuration. 50 | 51 | ## License 52 | 53 | MIT -------------------------------------------------------------------------------- /vite-project/src/typescript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vite-project/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/CalendarView.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Calendar from 'react-calendar'; 3 | import 'react-calendar/dist/Calendar.css'; 4 | import { db } from '../firebase'; 5 | import { collection, onSnapshot } from 'firebase/firestore'; 6 | import { format, isSameDay } from 'date-fns'; 7 | import './CalendarView.css'; 8 | 9 | function CalendarView() { 10 | const [value, setValue] = useState(new Date()); 11 | const [transactions, setTransactions] = useState([]); 12 | 13 | useEffect(() => { 14 | const unsub = onSnapshot(collection(db, 'transactions'), (snap) => { 15 | setTransactions(snap.docs.map((d) => ({ id: d.id, ...d.data() }))); 16 | }); 17 | return () => unsub(); 18 | }, []); 19 | 20 | const dailyTransactions = transactions.filter((tx) => isSameDay(tx.date.toDate(), value)); 21 | const incomeTotal = dailyTransactions.filter((t) => t.type === 'income').reduce((a, t) => a + t.amount, 0); 22 | const expenseTotal = dailyTransactions.filter((t) => (t.type || 'expense') === 'expense').reduce((a, t) => a + t.amount, 0); 23 | 24 | return ( 25 |
26 | 27 |
28 |

Transactions on {format(value, 'yyyy-MM-dd')}

29 |

Income: {incomeTotal.toLocaleString('vi-VN')} VND

30 |

Expense: {expenseTotal.toLocaleString('vi-VN')} VND

31 |
    32 | {dailyTransactions.map((tx) => ( 33 |
  • 34 | {tx.description} - {tx.amount.toLocaleString('vi-VN')} ({tx.category}) 35 |
  • 36 | ))} 37 |
38 |
39 |
40 | ); 41 | } 42 | 43 | export default CalendarView; -------------------------------------------------------------------------------- /vite-project/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | #app { 39 | max-width: 1280px; 40 | margin: 0 auto; 41 | padding: 2rem; 42 | text-align: center; 43 | } 44 | 45 | .logo { 46 | height: 6em; 47 | padding: 1.5em; 48 | will-change: filter; 49 | transition: filter 300ms; 50 | } 51 | .logo:hover { 52 | filter: drop-shadow(0 0 2em #646cffaa); 53 | } 54 | .logo.vanilla:hover { 55 | filter: drop-shadow(0 0 2em #3178c6aa); 56 | } 57 | 58 | .card { 59 | padding: 2em; 60 | } 61 | 62 | .read-the-docs { 63 | color: #888; 64 | } 65 | 66 | button { 67 | border-radius: 8px; 68 | border: 1px solid transparent; 69 | padding: 0.6em 1.2em; 70 | font-size: 1em; 71 | font-weight: 500; 72 | font-family: inherit; 73 | background-color: #1a1a1a; 74 | cursor: pointer; 75 | transition: border-color 0.25s; 76 | } 77 | button:hover { 78 | border-color: #646cff; 79 | } 80 | button:focus, 81 | button:focus-visible { 82 | outline: 4px auto -webkit-focus-ring-color; 83 | } 84 | 85 | @media (prefers-color-scheme: light) { 86 | :root { 87 | color: #213547; 88 | background-color: #ffffff; 89 | } 90 | a:hover { 91 | color: #747bff; 92 | } 93 | button { 94 | background-color: #f9f9f9; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/TransactionForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { db } from '../firebase'; 3 | import { collection, addDoc, Timestamp } from 'firebase/firestore'; 4 | import { formatISO } from 'date-fns'; 5 | import './TransactionForm.css'; 6 | 7 | const categories = [ 8 | 'Food & Drink', 9 | 'Transportation', 10 | 'Housing', 11 | 'Utilities', 12 | 'Entertainment', 13 | 'Shopping', 14 | 'Salary', 15 | 'Other', 16 | ]; 17 | 18 | function TransactionForm() { 19 | const [amount, setAmount] = useState(''); 20 | const [rawAmount, setRawAmount] = useState(''); 21 | const [description, setDescription] = useState(''); 22 | const [means, setMeans] = useState(''); 23 | const [type, setType] = useState(''); 24 | const [category, setCategory] = useState(''); 25 | const [date, setDate] = useState(formatISO(new Date(), { representation: 'date' })); 26 | 27 | const formatNumber = (value) => { 28 | if (!value) return ''; 29 | const num = value.replace(/,/g, ''); 30 | if (isNaN(num)) return ''; 31 | return parseFloat(num).toLocaleString('en-US'); 32 | }; 33 | 34 | const handleAmountChange = (e) => { 35 | const raw = e.target.value.replace(/,/g, ''); 36 | if (/^\d*$/.test(raw)) { 37 | setRawAmount(raw); 38 | setAmount(formatNumber(raw)); 39 | } 40 | }; 41 | 42 | const handleSubmit = async (e) => { 43 | e.preventDefault(); 44 | if (!rawAmount) return; 45 | try { 46 | await addDoc(collection(db, 'transactions'), { 47 | amount: parseFloat(rawAmount), 48 | description, 49 | means, 50 | type, 51 | category, 52 | date: Timestamp.fromDate(new Date(date)), 53 | createdAt: Timestamp.now(), 54 | }); 55 | setAmount(''); 56 | setRawAmount(''); 57 | setDescription(''); 58 | setMeans(''); 59 | } catch (err) { 60 | console.error('Error adding transaction', err); 61 | } 62 | }; 63 | 64 | return ( 65 |
66 | 76 | setDescription(e.target.value)} 81 | style={{ flex: '2 1 160px', minWidth: 80, maxWidth: 200, background: '#f3f4f6' }} 82 | /> 83 | 94 | 99 | 107 | setDate(e.target.value)} 111 | style={{ flex: '0 1 140px', minWidth: 120, maxWidth: 200, background: '#f3f4f6' }} 112 | /> 113 | 114 |
115 | ); 116 | } 117 | 118 | export default TransactionForm; -------------------------------------------------------------------------------- /src/components/StatsView.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { db } from '../firebase'; 3 | import { collection, onSnapshot } from 'firebase/firestore'; 4 | import { Bar, Pie } from 'react-chartjs-2'; 5 | import { 6 | Chart as ChartJS, 7 | CategoryScale, 8 | LinearScale, 9 | BarElement, 10 | ArcElement, 11 | Tooltip, 12 | Legend, 13 | } from 'chart.js'; 14 | import { format } from 'date-fns'; 15 | import './StatsView.css'; 16 | 17 | ChartJS.register(CategoryScale, LinearScale, BarElement, ArcElement, Tooltip, Legend); 18 | 19 | function aggregateByCategory(transactions) { 20 | const map = {}; 21 | transactions.forEach((tx) => { 22 | map[tx.category] = (map[tx.category] || 0) + tx.amount; 23 | }); 24 | return map; 25 | } 26 | 27 | function aggregateByMonth(transactions) { 28 | const map = {}; 29 | transactions.forEach((tx) => { 30 | const key = format(tx.date.toDate(), 'yyyy-MM'); 31 | map[key] = (map[key] || 0) + tx.amount; 32 | }); 33 | return map; 34 | } 35 | 36 | function StatsView() { 37 | const [transactions, setTransactions] = useState([]); 38 | 39 | useEffect(() => { 40 | const unsub = onSnapshot(collection(db, 'transactions'), (snap) => { 41 | setTransactions(snap.docs.map((d) => ({ id: d.id, ...d.data() }))); 42 | }); 43 | return () => unsub(); 44 | }, []); 45 | 46 | // Split by type 47 | const expenseTx = transactions.filter((t) => (t.type || 'expense') === 'expense'); 48 | const incomeTx = transactions.filter((t) => t.type === 'income'); 49 | 50 | // Split by wallet (cash/bank) 51 | const cashIncome = incomeTx.filter(t => (t.wallet || 'cash') === 'cash').reduce((a, t) => a + t.amount, 0); 52 | const bankIncome = incomeTx.filter(t => (t.wallet || 'cash') === 'bank').reduce((a, t) => a + t.amount, 0); 53 | const cashExpense = expenseTx.filter(t => (t.wallet || 'cash') === 'cash').reduce((a, t) => a + t.amount, 0); 54 | const bankExpense = expenseTx.filter(t => (t.wallet || 'cash') === 'bank').reduce((a, t) => a + t.amount, 0); 55 | 56 | const cashBalance = cashIncome - cashExpense; 57 | const bankBalance = bankIncome - bankExpense; 58 | 59 | // By category (expenses) 60 | const byCategory = aggregateByCategory(expenseTx); 61 | // By month (expenses) 62 | const byMonth = aggregateByMonth(expenseTx); 63 | // By month (income) 64 | const byMonthIncome = aggregateByMonth(incomeTx); 65 | 66 | const totalExpense = expenseTx.reduce((a, t) => a + t.amount, 0); 67 | const totalIncome = incomeTx.reduce((a, t) => a + t.amount, 0); 68 | 69 | const pieData = { 70 | labels: Object.keys(byCategory), 71 | datasets: [ 72 | { 73 | label: 'VND', 74 | data: Object.values(byCategory), 75 | backgroundColor: [ 76 | '#FF6384', 77 | '#36A2EB', 78 | '#FFCE56', 79 | '#4BC0C0', 80 | '#9966FF', 81 | '#FF9F40', 82 | ], 83 | }, 84 | ], 85 | }; 86 | 87 | const barData = { 88 | labels: Object.keys(byMonth), 89 | datasets: [ 90 | { 91 | label: 'VND per month (Expense)', 92 | data: Object.values(byMonth), 93 | backgroundColor: '#36A2EB', 94 | }, 95 | ], 96 | }; 97 | 98 | const barIncomeData = { 99 | labels: Object.keys(byMonthIncome), 100 | datasets: [ 101 | { 102 | label: 'VND per month (Income)', 103 | data: Object.values(byMonthIncome), 104 | backgroundColor: '#16a34a', 105 | }, 106 | ], 107 | }; 108 | 109 | return ( 110 |
111 |
112 |
113 |

Cash Balance

114 |
{cashBalance.toLocaleString('vi-VN')} VND
115 |
116 |
117 |

Bank Balance

118 |
{bankBalance.toLocaleString('vi-VN')} VND
119 |
120 |
121 |

Total Income: {totalIncome.toLocaleString('vi-VN')} VND

122 |

Total Expense: {totalExpense.toLocaleString('vi-VN')} VND

123 |

Expenses by Category

124 | 125 |

Expenses by Month

126 | 127 |

Income by Month

128 | 129 |
130 | ); 131 | } 132 | 133 | export default StatsView; -------------------------------------------------------------------------------- /src/components/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { FiHome, FiList, FiPieChart, FiCalendar, FiPlus, FiUser, FiSettings, FiLogOut, FiEdit2, FiTrash2, FiCheck, FiX, FiCreditCard } from 'react-icons/fi'; 3 | import { db } from '../firebase'; 4 | import { collection, addDoc, getDocs, updateDoc, deleteDoc, doc, onSnapshot, setDoc } from 'firebase/firestore'; 5 | 6 | export default function Sidebar({ onNav, currentView, onQuickAdd }) { 7 | const [items, setItems] = useState([]); 8 | const [newItem, setNewItem] = useState(''); 9 | const [editingIdx, setEditingIdx] = useState(null); 10 | const [editingText, setEditingText] = useState(''); 11 | const [balance, setBalance] = useState({ total: 0, cash: 0, bank: 0 }); 12 | const [editingBalance, setEditingBalance] = useState(false); 13 | const [balanceDraft, setBalanceDraft] = useState({ total: 0, cash: 0, bank: 0 }); 14 | 15 | // Fetch items from Firestore on mount and listen for changes 16 | useEffect(() => { 17 | const unsub = onSnapshot(collection(db, 'wishlist'), (snap) => { 18 | setItems(snap.docs.map(doc => ({ id: doc.id, ...doc.data() }))); 19 | }); 20 | return () => unsub(); 21 | }, []); 22 | 23 | // Fetch balance summary from Firestore 24 | useEffect(() => { 25 | const unsub = onSnapshot(doc(db, 'sidebarBalance', 'summary'), (snap) => { 26 | if (snap.exists()) { 27 | setBalance(snap.data()); 28 | if (!editingBalance) setBalanceDraft(snap.data()); 29 | } 30 | }); 31 | return () => unsub(); 32 | }, [editingBalance]); 33 | 34 | const handleAddItem = async (e) => { 35 | e.preventDefault(); 36 | if (newItem.trim()) { 37 | await addDoc(collection(db, 'wishlist'), { text: newItem.trim(), checked: false }); 38 | setNewItem(''); 39 | } 40 | }; 41 | const handleCheck = async (idx) => { 42 | const item = items[idx]; 43 | await updateDoc(doc(db, 'wishlist', item.id), { checked: !item.checked }); 44 | }; 45 | const handleRemove = async (idx) => { 46 | const item = items[idx]; 47 | await deleteDoc(doc(db, 'wishlist', item.id)); 48 | }; 49 | const handleEdit = (idx) => { 50 | setEditingIdx(idx); 51 | setEditingText(items[idx].text); 52 | }; 53 | const handleEditSave = async (idx) => { 54 | const item = items[idx]; 55 | await updateDoc(doc(db, 'wishlist', item.id), { text: editingText }); 56 | setEditingIdx(null); 57 | setEditingText(''); 58 | }; 59 | const handleEditCancel = () => { 60 | setEditingIdx(null); 61 | setEditingText(''); 62 | }; 63 | 64 | const handleBalanceEdit = () => { 65 | setEditingBalance(true); 66 | setBalanceDraft(balance); 67 | }; 68 | const handleBalanceChange = (field, value) => { 69 | setBalanceDraft({ ...balanceDraft, [field]: value }); 70 | }; 71 | const handleBalanceSave = async () => { 72 | await setDoc(doc(db, 'sidebarBalance', 'summary'), { 73 | total: Number(balanceDraft.total) || 0, 74 | cash: Number(balanceDraft.cash) || 0, 75 | bank: Number(balanceDraft.bank) || 0, 76 | }); 77 | setEditingBalance(false); 78 | }; 79 | const handleBalanceCancel = () => { 80 | setEditingBalance(false); 81 | setBalanceDraft(balance); 82 | }; 83 | 84 | const lastUpdated = new Date().toLocaleString(); 85 | 86 | // Format number to M (million) abbreviation 87 | const formatMillion = (num) => { 88 | if (Math.abs(num) >= 1_000_000) { 89 | return (num / 1_000_000).toFixed(num % 1_000_000 === 0 ? 0 : 1) + 'M'; 90 | } 91 | if (Math.abs(num) >= 1_000) { 92 | return (num / 1_000).toFixed(num % 1_000 === 0 ? 0 : 1) + 'K'; 93 | } 94 | return num.toLocaleString('vi-VN'); 95 | }; 96 | 97 | return ( 98 | 205 | ); 206 | } -------------------------------------------------------------------------------- /src/components/TransactionsList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { db } from '../firebase'; 3 | import { 4 | collection, 5 | query, 6 | orderBy, 7 | onSnapshot, 8 | doc, 9 | deleteDoc, 10 | updateDoc, 11 | } from 'firebase/firestore'; 12 | import { format, isSameMonth } from 'date-fns'; 13 | import './TransactionsList.css'; 14 | import { FiEdit, FiTrash2, FiCheck, FiDollarSign, FiTrendingDown, FiCoffee, FiTruck, FiHome, FiZap, FiFilm, FiShoppingBag, FiCreditCard, FiTag, FiDownload, FiChevronLeft, FiChevronRight } from 'react-icons/fi'; 15 | import * as XLSX from 'xlsx'; 16 | import { Pie, Line } from 'react-chartjs-2'; 17 | import { Chart as ChartJS, ArcElement, Tooltip, Legend, LineElement, PointElement, LinearScale, CategoryScale } from 'chart.js'; 18 | 19 | ChartJS.register(ArcElement, Tooltip, Legend, LineElement, PointElement, LinearScale, CategoryScale); 20 | 21 | function TransactionsList() { 22 | const [transactions, setTransactions] = useState([]); 23 | const [editingId, setEditingId] = useState(null); 24 | const [formState, setFormState] = useState({ description: '', amount: '', category: '' }); 25 | 26 | useEffect(() => { 27 | const q = query(collection(db, 'transactions'), orderBy('date', 'desc')); 28 | const unsub = onSnapshot(q, (snap) => { 29 | const data = snap.docs.map((d) => ({ id: d.id, ...d.data() })); 30 | setTransactions(data); 31 | }); 32 | return () => unsub(); 33 | }, []); 34 | 35 | const handleDelete = async (id) => { 36 | if (confirm('Delete transaction?')) { 37 | await deleteDoc(doc(db, 'transactions', id)); 38 | } 39 | }; 40 | 41 | const startEdit = (tx) => { 42 | setEditingId(tx.id); 43 | setFormState({ 44 | description: tx.description, 45 | amount: tx.amount, 46 | category: tx.category, 47 | }); 48 | }; 49 | 50 | const handleEditSave = async (id) => { 51 | await updateDoc(doc(db, 'transactions', id), { 52 | description: formState.description, 53 | amount: parseFloat(formState.amount), 54 | category: formState.category, 55 | }); 56 | setEditingId(null); 57 | setFormState({ description: '', amount: '', category: '' }); 58 | }; 59 | 60 | const handleExportExcel = () => { 61 | // Prepare data for export 62 | const data = transactions.map((tx) => ({ 63 | Date: format(tx.date.toDate(), 'yyyy-MM-dd'), 64 | Description: tx.description, 65 | Category: tx.category, 66 | 'Amount (VND)': tx.amount, 67 | Type: tx.type || 'expense', 68 | })); 69 | const worksheet = XLSX.utils.json_to_sheet(data); 70 | const workbook = XLSX.utils.book_new(); 71 | XLSX.utils.book_append_sheet(workbook, worksheet, 'Transactions'); 72 | XLSX.writeFile(workbook, 'transactions.xlsx'); 73 | }; 74 | 75 | // Calculate total balance 76 | const totalIncome = transactions.filter(t => t.type === 'income').reduce((a, t) => a + t.amount, 0); 77 | const totalExpense = transactions.filter(t => (t.type || 'expense') === 'expense').reduce((a, t) => a + t.amount, 0); 78 | const totalBalance = totalIncome - totalExpense; 79 | 80 | // Format number to M (million) or K (thousand) abbreviation 81 | const formatMillion = (num) => { 82 | if (Math.abs(num) >= 1_000_000) { 83 | return (num / 1_000_000).toFixed(num % 1_000_000 === 0 ? 0 : 1) + 'M'; 84 | } 85 | if (Math.abs(num) >= 1_000) { 86 | return (num / 1_000).toFixed(num % 1_000 === 0 ? 0 : 1) + 'K'; 87 | } 88 | return num.toLocaleString('vi-VN'); 89 | }; 90 | 91 | // Calculate this month's expense 92 | const now = new Date(); 93 | const monthExpense = transactions.filter(t => (t.type || 'expense') === 'expense' && isSameMonth(t.date.toDate(), now)).reduce((a, t) => a + t.amount, 0); 94 | 95 | // Icon mapping for categories 96 | const categoryIcons = { 97 | 'Food & Drink': , 98 | 'Transportation': , 99 | 'Housing': , 100 | 'Utilities': , 101 | 'Entertainment': , 102 | 'Shopping': , 103 | 'Salary': , 104 | 'Other': , 105 | }; 106 | 107 | // Aggregate expenses by category for pie chart 108 | const expenseTx = transactions.filter((t) => (t.type || 'expense') === 'expense'); 109 | const byCategory = {}; 110 | expenseTx.forEach((tx) => { 111 | byCategory[tx.category] = (byCategory[tx.category] || 0) + tx.amount; 112 | }); 113 | const pieData = { 114 | labels: Object.keys(byCategory), 115 | datasets: [ 116 | { 117 | label: 'VND', 118 | data: Object.values(byCategory), 119 | backgroundColor: [ 120 | '#FF6384', 121 | '#36A2EB', 122 | '#FFCE56', 123 | '#4BC0C0', 124 | '#9966FF', 125 | '#FF9F40', 126 | ], 127 | }, 128 | ], 129 | }; 130 | 131 | // Line chart data: expense by date in current month 132 | const currentMonth = now.getMonth(); 133 | const currentYear = now.getFullYear(); 134 | const monthExpenseTx = expenseTx.filter(t => { 135 | const d = t.date.toDate(); 136 | return d.getMonth() === currentMonth && d.getFullYear() === currentYear; 137 | }); 138 | const byDate = {}; 139 | monthExpenseTx.forEach((tx) => { 140 | const key = format(tx.date.toDate(), 'yyyy-MM-dd'); 141 | byDate[key] = (byDate[key] || 0) + tx.amount; 142 | }); 143 | // Fill missing days with 0 144 | const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); 145 | const dateLabels = Array.from({ length: daysInMonth }, (_, i) => format(new Date(currentYear, currentMonth, i + 1), 'yyyy-MM-dd')); 146 | const lineData = { 147 | labels: dateLabels, 148 | datasets: [ 149 | { 150 | label: 'Expense', 151 | data: dateLabels.map(date => byDate[date] || 0), 152 | borderColor: '#dc2626', 153 | backgroundColor: 'rgba(220,38,38,0.1)', 154 | fill: true, 155 | tension: 0.3, 156 | pointRadius: 2, 157 | }, 158 | ], 159 | }; 160 | 161 | // Quotes for the quote tile 162 | const quotes = [ 163 | 'Do not save what is left after spending, but spend what is left after saving.\n- Warren Buffett', 164 | 'It\'s not your salary that makes you rich, it\'s your spending habits.\n- Charles A. Jaffe', 165 | 'Beware of little expenses; a small leak will sink a great ship.\n- Benjamin Franklin', 166 | 'The art is not in making money, but in keeping it.\n- Proverb', 167 | 'A budget is telling your money where to go instead of wondering where it went.\n- Dave Ramsey', 168 | ]; 169 | const [quoteIdx, setQuoteIdx] = useState(0); 170 | const handlePrevQuote = () => setQuoteIdx((prev) => (prev === 0 ? quotes.length - 1 : prev - 1)); 171 | const handleNextQuote = () => setQuoteIdx((prev) => (prev === quotes.length - 1 ? 0 : prev + 1)); 172 | 173 | return ( 174 | <> 175 |
176 |
177 |
178 | {/* Tiles */} 179 |
180 |

Total Balance

181 |
= 0 ? '#16a34a' : '#dc2626' }}> 182 | = 0 ? '#16a34a' : '#dc2626' }} /> 183 | {formatMillion(totalBalance)} 184 |
185 |
186 |
187 |

Monthly Expense

188 |
189 | 190 | {formatMillion(monthExpense)} 191 |
192 |
193 |
194 | {/* Line chart below tiles */} 195 |
196 |

Expense by Date (This Month)

197 | 198 |
199 |
200 | {/* Pie chart */} 201 |
202 |
203 |

Expense by Category

204 | 209 |
210 |
211 | 212 | {quotes[quoteIdx]} 213 | 214 |
215 |
216 |
217 |
218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 247 | 248 | 249 |
DateDescriptionCategoryAmount (VND) 227 | 246 |
250 |
251 | 252 | 253 | {transactions.map((tx) => ( 254 | 255 | 256 | 266 | 279 | 290 | 300 | 301 | 302 | ))} 303 | 304 |
{format(tx.date.toDate(), 'yyyy-MM-dd')} 257 | {editingId === tx.id ? ( 258 | setFormState({ ...formState, description: e.target.value })} 261 | /> 262 | ) : ( 263 | tx.description 264 | )} 265 | 267 | {editingId === tx.id ? ( 268 | setFormState({ ...formState, category: e.target.value })} 271 | /> 272 | ) : ( 273 | 274 | {categoryIcons[tx.category] || null} 275 | {tx.category} 276 | 277 | )} 278 | 280 | {editingId === tx.id ? ( 281 | setFormState({ ...formState, amount: e.target.value })} 285 | /> 286 | ) : ( 287 | `${tx.type === 'income' ? '+' : '-'}${tx.amount.toLocaleString('vi-VN')}` 288 | )} 289 | 291 | {editingId === tx.id ? ( 292 | 293 | ) : ( 294 | <> 295 | 296 | 297 | 298 | )} 299 |
305 |
306 |
307 | 308 | ); 309 | } 310 | 311 | export default TransactionsList; -------------------------------------------------------------------------------- /vite-project/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-project", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "vite-project", 9 | "version": "0.0.0", 10 | "devDependencies": { 11 | "typescript": "~5.9.3", 12 | "vite": "^7.1.7" 13 | } 14 | }, 15 | "node_modules/@esbuild/aix-ppc64": { 16 | "version": "0.25.11", 17 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", 18 | "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", 19 | "cpu": [ 20 | "ppc64" 21 | ], 22 | "dev": true, 23 | "license": "MIT", 24 | "optional": true, 25 | "os": [ 26 | "aix" 27 | ], 28 | "engines": { 29 | "node": ">=18" 30 | } 31 | }, 32 | "node_modules/@esbuild/android-arm": { 33 | "version": "0.25.11", 34 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", 35 | "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", 36 | "cpu": [ 37 | "arm" 38 | ], 39 | "dev": true, 40 | "license": "MIT", 41 | "optional": true, 42 | "os": [ 43 | "android" 44 | ], 45 | "engines": { 46 | "node": ">=18" 47 | } 48 | }, 49 | "node_modules/@esbuild/android-arm64": { 50 | "version": "0.25.11", 51 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", 52 | "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", 53 | "cpu": [ 54 | "arm64" 55 | ], 56 | "dev": true, 57 | "license": "MIT", 58 | "optional": true, 59 | "os": [ 60 | "android" 61 | ], 62 | "engines": { 63 | "node": ">=18" 64 | } 65 | }, 66 | "node_modules/@esbuild/android-x64": { 67 | "version": "0.25.11", 68 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", 69 | "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", 70 | "cpu": [ 71 | "x64" 72 | ], 73 | "dev": true, 74 | "license": "MIT", 75 | "optional": true, 76 | "os": [ 77 | "android" 78 | ], 79 | "engines": { 80 | "node": ">=18" 81 | } 82 | }, 83 | "node_modules/@esbuild/darwin-arm64": { 84 | "version": "0.25.11", 85 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", 86 | "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", 87 | "cpu": [ 88 | "arm64" 89 | ], 90 | "dev": true, 91 | "license": "MIT", 92 | "optional": true, 93 | "os": [ 94 | "darwin" 95 | ], 96 | "engines": { 97 | "node": ">=18" 98 | } 99 | }, 100 | "node_modules/@esbuild/darwin-x64": { 101 | "version": "0.25.11", 102 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", 103 | "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", 104 | "cpu": [ 105 | "x64" 106 | ], 107 | "dev": true, 108 | "license": "MIT", 109 | "optional": true, 110 | "os": [ 111 | "darwin" 112 | ], 113 | "engines": { 114 | "node": ">=18" 115 | } 116 | }, 117 | "node_modules/@esbuild/freebsd-arm64": { 118 | "version": "0.25.11", 119 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", 120 | "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", 121 | "cpu": [ 122 | "arm64" 123 | ], 124 | "dev": true, 125 | "license": "MIT", 126 | "optional": true, 127 | "os": [ 128 | "freebsd" 129 | ], 130 | "engines": { 131 | "node": ">=18" 132 | } 133 | }, 134 | "node_modules/@esbuild/freebsd-x64": { 135 | "version": "0.25.11", 136 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", 137 | "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", 138 | "cpu": [ 139 | "x64" 140 | ], 141 | "dev": true, 142 | "license": "MIT", 143 | "optional": true, 144 | "os": [ 145 | "freebsd" 146 | ], 147 | "engines": { 148 | "node": ">=18" 149 | } 150 | }, 151 | "node_modules/@esbuild/linux-arm": { 152 | "version": "0.25.11", 153 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", 154 | "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", 155 | "cpu": [ 156 | "arm" 157 | ], 158 | "dev": true, 159 | "license": "MIT", 160 | "optional": true, 161 | "os": [ 162 | "linux" 163 | ], 164 | "engines": { 165 | "node": ">=18" 166 | } 167 | }, 168 | "node_modules/@esbuild/linux-arm64": { 169 | "version": "0.25.11", 170 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", 171 | "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", 172 | "cpu": [ 173 | "arm64" 174 | ], 175 | "dev": true, 176 | "license": "MIT", 177 | "optional": true, 178 | "os": [ 179 | "linux" 180 | ], 181 | "engines": { 182 | "node": ">=18" 183 | } 184 | }, 185 | "node_modules/@esbuild/linux-ia32": { 186 | "version": "0.25.11", 187 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", 188 | "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", 189 | "cpu": [ 190 | "ia32" 191 | ], 192 | "dev": true, 193 | "license": "MIT", 194 | "optional": true, 195 | "os": [ 196 | "linux" 197 | ], 198 | "engines": { 199 | "node": ">=18" 200 | } 201 | }, 202 | "node_modules/@esbuild/linux-loong64": { 203 | "version": "0.25.11", 204 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", 205 | "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", 206 | "cpu": [ 207 | "loong64" 208 | ], 209 | "dev": true, 210 | "license": "MIT", 211 | "optional": true, 212 | "os": [ 213 | "linux" 214 | ], 215 | "engines": { 216 | "node": ">=18" 217 | } 218 | }, 219 | "node_modules/@esbuild/linux-mips64el": { 220 | "version": "0.25.11", 221 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", 222 | "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", 223 | "cpu": [ 224 | "mips64el" 225 | ], 226 | "dev": true, 227 | "license": "MIT", 228 | "optional": true, 229 | "os": [ 230 | "linux" 231 | ], 232 | "engines": { 233 | "node": ">=18" 234 | } 235 | }, 236 | "node_modules/@esbuild/linux-ppc64": { 237 | "version": "0.25.11", 238 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", 239 | "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", 240 | "cpu": [ 241 | "ppc64" 242 | ], 243 | "dev": true, 244 | "license": "MIT", 245 | "optional": true, 246 | "os": [ 247 | "linux" 248 | ], 249 | "engines": { 250 | "node": ">=18" 251 | } 252 | }, 253 | "node_modules/@esbuild/linux-riscv64": { 254 | "version": "0.25.11", 255 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", 256 | "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", 257 | "cpu": [ 258 | "riscv64" 259 | ], 260 | "dev": true, 261 | "license": "MIT", 262 | "optional": true, 263 | "os": [ 264 | "linux" 265 | ], 266 | "engines": { 267 | "node": ">=18" 268 | } 269 | }, 270 | "node_modules/@esbuild/linux-s390x": { 271 | "version": "0.25.11", 272 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", 273 | "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", 274 | "cpu": [ 275 | "s390x" 276 | ], 277 | "dev": true, 278 | "license": "MIT", 279 | "optional": true, 280 | "os": [ 281 | "linux" 282 | ], 283 | "engines": { 284 | "node": ">=18" 285 | } 286 | }, 287 | "node_modules/@esbuild/linux-x64": { 288 | "version": "0.25.11", 289 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", 290 | "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", 291 | "cpu": [ 292 | "x64" 293 | ], 294 | "dev": true, 295 | "license": "MIT", 296 | "optional": true, 297 | "os": [ 298 | "linux" 299 | ], 300 | "engines": { 301 | "node": ">=18" 302 | } 303 | }, 304 | "node_modules/@esbuild/netbsd-arm64": { 305 | "version": "0.25.11", 306 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", 307 | "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", 308 | "cpu": [ 309 | "arm64" 310 | ], 311 | "dev": true, 312 | "license": "MIT", 313 | "optional": true, 314 | "os": [ 315 | "netbsd" 316 | ], 317 | "engines": { 318 | "node": ">=18" 319 | } 320 | }, 321 | "node_modules/@esbuild/netbsd-x64": { 322 | "version": "0.25.11", 323 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", 324 | "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", 325 | "cpu": [ 326 | "x64" 327 | ], 328 | "dev": true, 329 | "license": "MIT", 330 | "optional": true, 331 | "os": [ 332 | "netbsd" 333 | ], 334 | "engines": { 335 | "node": ">=18" 336 | } 337 | }, 338 | "node_modules/@esbuild/openbsd-arm64": { 339 | "version": "0.25.11", 340 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", 341 | "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", 342 | "cpu": [ 343 | "arm64" 344 | ], 345 | "dev": true, 346 | "license": "MIT", 347 | "optional": true, 348 | "os": [ 349 | "openbsd" 350 | ], 351 | "engines": { 352 | "node": ">=18" 353 | } 354 | }, 355 | "node_modules/@esbuild/openbsd-x64": { 356 | "version": "0.25.11", 357 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", 358 | "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", 359 | "cpu": [ 360 | "x64" 361 | ], 362 | "dev": true, 363 | "license": "MIT", 364 | "optional": true, 365 | "os": [ 366 | "openbsd" 367 | ], 368 | "engines": { 369 | "node": ">=18" 370 | } 371 | }, 372 | "node_modules/@esbuild/openharmony-arm64": { 373 | "version": "0.25.11", 374 | "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", 375 | "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", 376 | "cpu": [ 377 | "arm64" 378 | ], 379 | "dev": true, 380 | "license": "MIT", 381 | "optional": true, 382 | "os": [ 383 | "openharmony" 384 | ], 385 | "engines": { 386 | "node": ">=18" 387 | } 388 | }, 389 | "node_modules/@esbuild/sunos-x64": { 390 | "version": "0.25.11", 391 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", 392 | "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", 393 | "cpu": [ 394 | "x64" 395 | ], 396 | "dev": true, 397 | "license": "MIT", 398 | "optional": true, 399 | "os": [ 400 | "sunos" 401 | ], 402 | "engines": { 403 | "node": ">=18" 404 | } 405 | }, 406 | "node_modules/@esbuild/win32-arm64": { 407 | "version": "0.25.11", 408 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", 409 | "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", 410 | "cpu": [ 411 | "arm64" 412 | ], 413 | "dev": true, 414 | "license": "MIT", 415 | "optional": true, 416 | "os": [ 417 | "win32" 418 | ], 419 | "engines": { 420 | "node": ">=18" 421 | } 422 | }, 423 | "node_modules/@esbuild/win32-ia32": { 424 | "version": "0.25.11", 425 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", 426 | "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", 427 | "cpu": [ 428 | "ia32" 429 | ], 430 | "dev": true, 431 | "license": "MIT", 432 | "optional": true, 433 | "os": [ 434 | "win32" 435 | ], 436 | "engines": { 437 | "node": ">=18" 438 | } 439 | }, 440 | "node_modules/@esbuild/win32-x64": { 441 | "version": "0.25.11", 442 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", 443 | "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", 444 | "cpu": [ 445 | "x64" 446 | ], 447 | "dev": true, 448 | "license": "MIT", 449 | "optional": true, 450 | "os": [ 451 | "win32" 452 | ], 453 | "engines": { 454 | "node": ">=18" 455 | } 456 | }, 457 | "node_modules/@rollup/rollup-android-arm-eabi": { 458 | "version": "4.52.5", 459 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", 460 | "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", 461 | "cpu": [ 462 | "arm" 463 | ], 464 | "dev": true, 465 | "license": "MIT", 466 | "optional": true, 467 | "os": [ 468 | "android" 469 | ] 470 | }, 471 | "node_modules/@rollup/rollup-android-arm64": { 472 | "version": "4.52.5", 473 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", 474 | "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", 475 | "cpu": [ 476 | "arm64" 477 | ], 478 | "dev": true, 479 | "license": "MIT", 480 | "optional": true, 481 | "os": [ 482 | "android" 483 | ] 484 | }, 485 | "node_modules/@rollup/rollup-darwin-arm64": { 486 | "version": "4.52.5", 487 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", 488 | "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", 489 | "cpu": [ 490 | "arm64" 491 | ], 492 | "dev": true, 493 | "license": "MIT", 494 | "optional": true, 495 | "os": [ 496 | "darwin" 497 | ] 498 | }, 499 | "node_modules/@rollup/rollup-darwin-x64": { 500 | "version": "4.52.5", 501 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", 502 | "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", 503 | "cpu": [ 504 | "x64" 505 | ], 506 | "dev": true, 507 | "license": "MIT", 508 | "optional": true, 509 | "os": [ 510 | "darwin" 511 | ] 512 | }, 513 | "node_modules/@rollup/rollup-freebsd-arm64": { 514 | "version": "4.52.5", 515 | "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", 516 | "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", 517 | "cpu": [ 518 | "arm64" 519 | ], 520 | "dev": true, 521 | "license": "MIT", 522 | "optional": true, 523 | "os": [ 524 | "freebsd" 525 | ] 526 | }, 527 | "node_modules/@rollup/rollup-freebsd-x64": { 528 | "version": "4.52.5", 529 | "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", 530 | "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", 531 | "cpu": [ 532 | "x64" 533 | ], 534 | "dev": true, 535 | "license": "MIT", 536 | "optional": true, 537 | "os": [ 538 | "freebsd" 539 | ] 540 | }, 541 | "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 542 | "version": "4.52.5", 543 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", 544 | "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", 545 | "cpu": [ 546 | "arm" 547 | ], 548 | "dev": true, 549 | "license": "MIT", 550 | "optional": true, 551 | "os": [ 552 | "linux" 553 | ] 554 | }, 555 | "node_modules/@rollup/rollup-linux-arm-musleabihf": { 556 | "version": "4.52.5", 557 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", 558 | "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", 559 | "cpu": [ 560 | "arm" 561 | ], 562 | "dev": true, 563 | "license": "MIT", 564 | "optional": true, 565 | "os": [ 566 | "linux" 567 | ] 568 | }, 569 | "node_modules/@rollup/rollup-linux-arm64-gnu": { 570 | "version": "4.52.5", 571 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", 572 | "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", 573 | "cpu": [ 574 | "arm64" 575 | ], 576 | "dev": true, 577 | "license": "MIT", 578 | "optional": true, 579 | "os": [ 580 | "linux" 581 | ] 582 | }, 583 | "node_modules/@rollup/rollup-linux-arm64-musl": { 584 | "version": "4.52.5", 585 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", 586 | "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", 587 | "cpu": [ 588 | "arm64" 589 | ], 590 | "dev": true, 591 | "license": "MIT", 592 | "optional": true, 593 | "os": [ 594 | "linux" 595 | ] 596 | }, 597 | "node_modules/@rollup/rollup-linux-loong64-gnu": { 598 | "version": "4.52.5", 599 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", 600 | "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", 601 | "cpu": [ 602 | "loong64" 603 | ], 604 | "dev": true, 605 | "license": "MIT", 606 | "optional": true, 607 | "os": [ 608 | "linux" 609 | ] 610 | }, 611 | "node_modules/@rollup/rollup-linux-ppc64-gnu": { 612 | "version": "4.52.5", 613 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", 614 | "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", 615 | "cpu": [ 616 | "ppc64" 617 | ], 618 | "dev": true, 619 | "license": "MIT", 620 | "optional": true, 621 | "os": [ 622 | "linux" 623 | ] 624 | }, 625 | "node_modules/@rollup/rollup-linux-riscv64-gnu": { 626 | "version": "4.52.5", 627 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", 628 | "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", 629 | "cpu": [ 630 | "riscv64" 631 | ], 632 | "dev": true, 633 | "license": "MIT", 634 | "optional": true, 635 | "os": [ 636 | "linux" 637 | ] 638 | }, 639 | "node_modules/@rollup/rollup-linux-riscv64-musl": { 640 | "version": "4.52.5", 641 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", 642 | "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", 643 | "cpu": [ 644 | "riscv64" 645 | ], 646 | "dev": true, 647 | "license": "MIT", 648 | "optional": true, 649 | "os": [ 650 | "linux" 651 | ] 652 | }, 653 | "node_modules/@rollup/rollup-linux-s390x-gnu": { 654 | "version": "4.52.5", 655 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", 656 | "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", 657 | "cpu": [ 658 | "s390x" 659 | ], 660 | "dev": true, 661 | "license": "MIT", 662 | "optional": true, 663 | "os": [ 664 | "linux" 665 | ] 666 | }, 667 | "node_modules/@rollup/rollup-linux-x64-gnu": { 668 | "version": "4.52.5", 669 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", 670 | "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", 671 | "cpu": [ 672 | "x64" 673 | ], 674 | "dev": true, 675 | "license": "MIT", 676 | "optional": true, 677 | "os": [ 678 | "linux" 679 | ] 680 | }, 681 | "node_modules/@rollup/rollup-linux-x64-musl": { 682 | "version": "4.52.5", 683 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", 684 | "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", 685 | "cpu": [ 686 | "x64" 687 | ], 688 | "dev": true, 689 | "license": "MIT", 690 | "optional": true, 691 | "os": [ 692 | "linux" 693 | ] 694 | }, 695 | "node_modules/@rollup/rollup-openharmony-arm64": { 696 | "version": "4.52.5", 697 | "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", 698 | "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", 699 | "cpu": [ 700 | "arm64" 701 | ], 702 | "dev": true, 703 | "license": "MIT", 704 | "optional": true, 705 | "os": [ 706 | "openharmony" 707 | ] 708 | }, 709 | "node_modules/@rollup/rollup-win32-arm64-msvc": { 710 | "version": "4.52.5", 711 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", 712 | "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", 713 | "cpu": [ 714 | "arm64" 715 | ], 716 | "dev": true, 717 | "license": "MIT", 718 | "optional": true, 719 | "os": [ 720 | "win32" 721 | ] 722 | }, 723 | "node_modules/@rollup/rollup-win32-ia32-msvc": { 724 | "version": "4.52.5", 725 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", 726 | "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", 727 | "cpu": [ 728 | "ia32" 729 | ], 730 | "dev": true, 731 | "license": "MIT", 732 | "optional": true, 733 | "os": [ 734 | "win32" 735 | ] 736 | }, 737 | "node_modules/@rollup/rollup-win32-x64-gnu": { 738 | "version": "4.52.5", 739 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", 740 | "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", 741 | "cpu": [ 742 | "x64" 743 | ], 744 | "dev": true, 745 | "license": "MIT", 746 | "optional": true, 747 | "os": [ 748 | "win32" 749 | ] 750 | }, 751 | "node_modules/@rollup/rollup-win32-x64-msvc": { 752 | "version": "4.52.5", 753 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", 754 | "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", 755 | "cpu": [ 756 | "x64" 757 | ], 758 | "dev": true, 759 | "license": "MIT", 760 | "optional": true, 761 | "os": [ 762 | "win32" 763 | ] 764 | }, 765 | "node_modules/@types/estree": { 766 | "version": "1.0.8", 767 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 768 | "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 769 | "dev": true, 770 | "license": "MIT" 771 | }, 772 | "node_modules/esbuild": { 773 | "version": "0.25.11", 774 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", 775 | "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", 776 | "dev": true, 777 | "hasInstallScript": true, 778 | "license": "MIT", 779 | "bin": { 780 | "esbuild": "bin/esbuild" 781 | }, 782 | "engines": { 783 | "node": ">=18" 784 | }, 785 | "optionalDependencies": { 786 | "@esbuild/aix-ppc64": "0.25.11", 787 | "@esbuild/android-arm": "0.25.11", 788 | "@esbuild/android-arm64": "0.25.11", 789 | "@esbuild/android-x64": "0.25.11", 790 | "@esbuild/darwin-arm64": "0.25.11", 791 | "@esbuild/darwin-x64": "0.25.11", 792 | "@esbuild/freebsd-arm64": "0.25.11", 793 | "@esbuild/freebsd-x64": "0.25.11", 794 | "@esbuild/linux-arm": "0.25.11", 795 | "@esbuild/linux-arm64": "0.25.11", 796 | "@esbuild/linux-ia32": "0.25.11", 797 | "@esbuild/linux-loong64": "0.25.11", 798 | "@esbuild/linux-mips64el": "0.25.11", 799 | "@esbuild/linux-ppc64": "0.25.11", 800 | "@esbuild/linux-riscv64": "0.25.11", 801 | "@esbuild/linux-s390x": "0.25.11", 802 | "@esbuild/linux-x64": "0.25.11", 803 | "@esbuild/netbsd-arm64": "0.25.11", 804 | "@esbuild/netbsd-x64": "0.25.11", 805 | "@esbuild/openbsd-arm64": "0.25.11", 806 | "@esbuild/openbsd-x64": "0.25.11", 807 | "@esbuild/openharmony-arm64": "0.25.11", 808 | "@esbuild/sunos-x64": "0.25.11", 809 | "@esbuild/win32-arm64": "0.25.11", 810 | "@esbuild/win32-ia32": "0.25.11", 811 | "@esbuild/win32-x64": "0.25.11" 812 | } 813 | }, 814 | "node_modules/fdir": { 815 | "version": "6.5.0", 816 | "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 817 | "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 818 | "dev": true, 819 | "license": "MIT", 820 | "engines": { 821 | "node": ">=12.0.0" 822 | }, 823 | "peerDependencies": { 824 | "picomatch": "^3 || ^4" 825 | }, 826 | "peerDependenciesMeta": { 827 | "picomatch": { 828 | "optional": true 829 | } 830 | } 831 | }, 832 | "node_modules/fsevents": { 833 | "version": "2.3.3", 834 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 835 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 836 | "dev": true, 837 | "hasInstallScript": true, 838 | "license": "MIT", 839 | "optional": true, 840 | "os": [ 841 | "darwin" 842 | ], 843 | "engines": { 844 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 845 | } 846 | }, 847 | "node_modules/nanoid": { 848 | "version": "3.3.11", 849 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 850 | "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 851 | "dev": true, 852 | "funding": [ 853 | { 854 | "type": "github", 855 | "url": "https://github.com/sponsors/ai" 856 | } 857 | ], 858 | "license": "MIT", 859 | "bin": { 860 | "nanoid": "bin/nanoid.cjs" 861 | }, 862 | "engines": { 863 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 864 | } 865 | }, 866 | "node_modules/picocolors": { 867 | "version": "1.1.1", 868 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 869 | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 870 | "dev": true, 871 | "license": "ISC" 872 | }, 873 | "node_modules/picomatch": { 874 | "version": "4.0.3", 875 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 876 | "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 877 | "dev": true, 878 | "license": "MIT", 879 | "engines": { 880 | "node": ">=12" 881 | }, 882 | "funding": { 883 | "url": "https://github.com/sponsors/jonschlinkert" 884 | } 885 | }, 886 | "node_modules/postcss": { 887 | "version": "8.5.6", 888 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", 889 | "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", 890 | "dev": true, 891 | "funding": [ 892 | { 893 | "type": "opencollective", 894 | "url": "https://opencollective.com/postcss/" 895 | }, 896 | { 897 | "type": "tidelift", 898 | "url": "https://tidelift.com/funding/github/npm/postcss" 899 | }, 900 | { 901 | "type": "github", 902 | "url": "https://github.com/sponsors/ai" 903 | } 904 | ], 905 | "license": "MIT", 906 | "dependencies": { 907 | "nanoid": "^3.3.11", 908 | "picocolors": "^1.1.1", 909 | "source-map-js": "^1.2.1" 910 | }, 911 | "engines": { 912 | "node": "^10 || ^12 || >=14" 913 | } 914 | }, 915 | "node_modules/rollup": { 916 | "version": "4.52.5", 917 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", 918 | "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", 919 | "dev": true, 920 | "license": "MIT", 921 | "dependencies": { 922 | "@types/estree": "1.0.8" 923 | }, 924 | "bin": { 925 | "rollup": "dist/bin/rollup" 926 | }, 927 | "engines": { 928 | "node": ">=18.0.0", 929 | "npm": ">=8.0.0" 930 | }, 931 | "optionalDependencies": { 932 | "@rollup/rollup-android-arm-eabi": "4.52.5", 933 | "@rollup/rollup-android-arm64": "4.52.5", 934 | "@rollup/rollup-darwin-arm64": "4.52.5", 935 | "@rollup/rollup-darwin-x64": "4.52.5", 936 | "@rollup/rollup-freebsd-arm64": "4.52.5", 937 | "@rollup/rollup-freebsd-x64": "4.52.5", 938 | "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", 939 | "@rollup/rollup-linux-arm-musleabihf": "4.52.5", 940 | "@rollup/rollup-linux-arm64-gnu": "4.52.5", 941 | "@rollup/rollup-linux-arm64-musl": "4.52.5", 942 | "@rollup/rollup-linux-loong64-gnu": "4.52.5", 943 | "@rollup/rollup-linux-ppc64-gnu": "4.52.5", 944 | "@rollup/rollup-linux-riscv64-gnu": "4.52.5", 945 | "@rollup/rollup-linux-riscv64-musl": "4.52.5", 946 | "@rollup/rollup-linux-s390x-gnu": "4.52.5", 947 | "@rollup/rollup-linux-x64-gnu": "4.52.5", 948 | "@rollup/rollup-linux-x64-musl": "4.52.5", 949 | "@rollup/rollup-openharmony-arm64": "4.52.5", 950 | "@rollup/rollup-win32-arm64-msvc": "4.52.5", 951 | "@rollup/rollup-win32-ia32-msvc": "4.52.5", 952 | "@rollup/rollup-win32-x64-gnu": "4.52.5", 953 | "@rollup/rollup-win32-x64-msvc": "4.52.5", 954 | "fsevents": "~2.3.2" 955 | } 956 | }, 957 | "node_modules/source-map-js": { 958 | "version": "1.2.1", 959 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 960 | "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 961 | "dev": true, 962 | "license": "BSD-3-Clause", 963 | "engines": { 964 | "node": ">=0.10.0" 965 | } 966 | }, 967 | "node_modules/tinyglobby": { 968 | "version": "0.2.15", 969 | "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 970 | "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 971 | "dev": true, 972 | "license": "MIT", 973 | "dependencies": { 974 | "fdir": "^6.5.0", 975 | "picomatch": "^4.0.3" 976 | }, 977 | "engines": { 978 | "node": ">=12.0.0" 979 | }, 980 | "funding": { 981 | "url": "https://github.com/sponsors/SuperchupuDev" 982 | } 983 | }, 984 | "node_modules/typescript": { 985 | "version": "5.9.3", 986 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 987 | "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 988 | "dev": true, 989 | "license": "Apache-2.0", 990 | "bin": { 991 | "tsc": "bin/tsc", 992 | "tsserver": "bin/tsserver" 993 | }, 994 | "engines": { 995 | "node": ">=14.17" 996 | } 997 | }, 998 | "node_modules/vite": { 999 | "version": "7.1.12", 1000 | "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", 1001 | "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", 1002 | "dev": true, 1003 | "license": "MIT", 1004 | "dependencies": { 1005 | "esbuild": "^0.25.0", 1006 | "fdir": "^6.5.0", 1007 | "picomatch": "^4.0.3", 1008 | "postcss": "^8.5.6", 1009 | "rollup": "^4.43.0", 1010 | "tinyglobby": "^0.2.15" 1011 | }, 1012 | "bin": { 1013 | "vite": "bin/vite.js" 1014 | }, 1015 | "engines": { 1016 | "node": "^20.19.0 || >=22.12.0" 1017 | }, 1018 | "funding": { 1019 | "url": "https://github.com/vitejs/vite?sponsor=1" 1020 | }, 1021 | "optionalDependencies": { 1022 | "fsevents": "~2.3.3" 1023 | }, 1024 | "peerDependencies": { 1025 | "@types/node": "^20.19.0 || >=22.12.0", 1026 | "jiti": ">=1.21.0", 1027 | "less": "^4.0.0", 1028 | "lightningcss": "^1.21.0", 1029 | "sass": "^1.70.0", 1030 | "sass-embedded": "^1.70.0", 1031 | "stylus": ">=0.54.8", 1032 | "sugarss": "^5.0.0", 1033 | "terser": "^5.16.0", 1034 | "tsx": "^4.8.1", 1035 | "yaml": "^2.4.2" 1036 | }, 1037 | "peerDependenciesMeta": { 1038 | "@types/node": { 1039 | "optional": true 1040 | }, 1041 | "jiti": { 1042 | "optional": true 1043 | }, 1044 | "less": { 1045 | "optional": true 1046 | }, 1047 | "lightningcss": { 1048 | "optional": true 1049 | }, 1050 | "sass": { 1051 | "optional": true 1052 | }, 1053 | "sass-embedded": { 1054 | "optional": true 1055 | }, 1056 | "stylus": { 1057 | "optional": true 1058 | }, 1059 | "sugarss": { 1060 | "optional": true 1061 | }, 1062 | "terser": { 1063 | "optional": true 1064 | }, 1065 | "tsx": { 1066 | "optional": true 1067 | }, 1068 | "yaml": { 1069 | "optional": true 1070 | } 1071 | } 1072 | } 1073 | } 1074 | } 1075 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; 2 | import { motion, AnimatePresence } from 'framer-motion'; 3 | import { Wallet, TrendingDown, Trash2, Edit3, Search, Plus, Download, MessageCircle, Send, X, PiggyBank, TrendingUp } from 'lucide-react'; 4 | import { 5 | ResponsiveContainer, 6 | AreaChart, 7 | Area, 8 | BarChart, 9 | Bar, 10 | XAxis, 11 | YAxis, 12 | CartesianGrid, 13 | Tooltip, 14 | Legend, 15 | PieChart, 16 | Pie, 17 | Cell, 18 | } from 'recharts'; 19 | import { 20 | getFirestore, 21 | collection, 22 | onSnapshot, 23 | addDoc, 24 | deleteDoc, 25 | doc, 26 | setDoc, 27 | updateDoc, 28 | serverTimestamp, 29 | query, 30 | orderBy, 31 | } from 'firebase/firestore'; 32 | import { db as exportedDb } from './firebase'; 33 | 34 | // Currency formatter 35 | const VND = new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }); 36 | 37 | const DEFAULT_INCOME_CATS = ['Salary', 'Bonus', 'Investments', 'Savings', 'Sales', 'Other (income)']; 38 | const DEFAULT_EXPENSE_CATS = [ 39 | 'Food & Dining', 40 | 'Transportation', 41 | 'Housing', 42 | 'Bills', 43 | 'Entertainment', 44 | 'Health', 45 | 'Education', 46 | 'Shopping', 47 | 'Other (expense)', 48 | ]; 49 | 50 | const COLORS = ['#112D4E', '#3F72AF', '#DBE2EF', '#F9F7F7']; 51 | 52 | const DEFAULT_SETTINGS = Object.freeze({ savingAmount: null, monthlyBudget: null }); 53 | const SETTINGS_STORAGE_KEY = 'vnd-settings'; 54 | const LEGACY_SETTINGS_KEYS = Object.freeze({ 55 | savingAmount: 'vnd-saving-amount', 56 | monthlyBudget: 'vnd-monthly-budget', 57 | }); 58 | const SETTINGS_COLLECTION = 'settings'; 59 | const SETTINGS_DOC_ID = 'global'; 60 | 61 | function useLocalStorage(key, initial) { 62 | const [value, setValue] = useState(() => { 63 | try { 64 | const raw = localStorage.getItem(key); 65 | return raw ? JSON.parse(raw) : initial; 66 | } catch { 67 | return initial; 68 | } 69 | }); 70 | useEffect(() => { 71 | try { 72 | localStorage.setItem(key, JSON.stringify(value)); 73 | } catch {} 74 | }, [key, value]); 75 | return [value, setValue]; 76 | } 77 | 78 | function toISO(d) { 79 | const pad = (n) => String(n).padStart(2, '0'); 80 | return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 81 | } 82 | 83 | function daysAgo(n) { 84 | const d = new Date(); 85 | d.setDate(d.getDate() - n); 86 | return toISO(d); 87 | } 88 | 89 | function classNames(...xs) { 90 | return xs.filter(Boolean).join(' '); 91 | } 92 | 93 | function useTransactions() { 94 | const [transactionsLS, setTransactionsLS] = useLocalStorage('vnd-finance-tracker', []); 95 | const [transactions, setTransactions] = useState([]); 96 | const [loading, setLoading] = useState(true); 97 | const db = exportedDb || getFirestore(); 98 | const source = db ? 'firestore' : 'local'; 99 | 100 | useEffect(() => { 101 | if (!db) { 102 | setTransactions(transactionsLS); 103 | setLoading(false); 104 | return; 105 | } 106 | setLoading(true); 107 | const q = query(collection(db, 'transactions'), orderBy('createdAt', 'desc')); 108 | const unsub = onSnapshot(q, (snap) => { 109 | const items = []; 110 | snap.forEach((d) => { 111 | const data = d.data(); 112 | items.push({ id: d.id, ...data }); 113 | }); 114 | setTransactions(items); 115 | setLoading(false); 116 | }); 117 | return () => unsub(); 118 | }, []); 119 | 120 | const add = async (tx) => { 121 | if (!db) { 122 | const item = { ...tx, id: (crypto?.randomUUID?.() || String(Math.random())), createdAt: new Date().toISOString() }; 123 | setTransactionsLS([item, ...transactionsLS]); 124 | setTransactions([item, ...transactions]); 125 | return; 126 | } 127 | await addDoc(collection(db, 'transactions'), { ...tx, createdAt: serverTimestamp() }); 128 | }; 129 | 130 | const remove = async (id) => { 131 | if (!db) { 132 | const next = transactions.filter((t) => t.id !== id); 133 | setTransactionsLS(next); 134 | setTransactions(next); 135 | return; 136 | } 137 | await deleteDoc(doc(db, 'transactions', id)); 138 | }; 139 | 140 | const update = async (id, patch) => { 141 | if (!db) { 142 | const next = transactions.map((t) => (t.id === id ? { ...t, ...patch } : t)); 143 | setTransactionsLS(next); 144 | setTransactions(next); 145 | return; 146 | } 147 | await updateDoc(doc(db, 'transactions', id), patch); 148 | }; 149 | 150 | return { transactions, add, remove, update, loading, source }; 151 | } 152 | 153 | function useFinanceSettings() { 154 | const [localSettings, setLocalSettings] = useLocalStorage(SETTINGS_STORAGE_KEY, DEFAULT_SETTINGS); 155 | const db = exportedDb || getFirestore(); 156 | const usingFirestore = Boolean(db); 157 | const [settings, setSettings] = useState(localSettings); 158 | const [loading, setLoading] = useState(usingFirestore); 159 | 160 | useEffect(() => { 161 | if (typeof window === 'undefined') return; 162 | try { 163 | const legacySavingsRaw = window.localStorage.getItem(LEGACY_SETTINGS_KEYS.savingAmount); 164 | const legacyBudgetRaw = window.localStorage.getItem(LEGACY_SETTINGS_KEYS.monthlyBudget); 165 | if (legacySavingsRaw !== null || legacyBudgetRaw !== null) { 166 | const migrated = { 167 | savingAmount: legacySavingsRaw !== null ? JSON.parse(legacySavingsRaw) : null, 168 | monthlyBudget: legacyBudgetRaw !== null ? JSON.parse(legacyBudgetRaw) : null, 169 | }; 170 | setLocalSettings(migrated); 171 | window.localStorage.removeItem(LEGACY_SETTINGS_KEYS.savingAmount); 172 | window.localStorage.removeItem(LEGACY_SETTINGS_KEYS.monthlyBudget); 173 | } 174 | } catch (err) { 175 | console.warn('Settings migration failed', err); 176 | } 177 | }, [setLocalSettings]); 178 | 179 | useEffect(() => { 180 | if (!usingFirestore) { 181 | return; 182 | } 183 | 184 | const ref = doc(db, SETTINGS_COLLECTION, SETTINGS_DOC_ID); 185 | setLoading(true); 186 | const unsubscribe = onSnapshot( 187 | ref, 188 | (snap) => { 189 | if (snap.exists()) { 190 | const data = snap.data(); 191 | const next = { 192 | savingAmount: data?.savingAmount ?? null, 193 | monthlyBudget: data?.monthlyBudget ?? null, 194 | }; 195 | setSettings(next); 196 | setLocalSettings(next); 197 | } else { 198 | setSettings(DEFAULT_SETTINGS); 199 | setDoc(ref, { ...DEFAULT_SETTINGS, updatedAt: serverTimestamp() }, { merge: true }).catch((err) => 200 | console.error('Failed to initialize settings document', err) 201 | ); 202 | } 203 | setLoading(false); 204 | }, 205 | (error) => { 206 | console.error('Settings snapshot error', error); 207 | setLoading(false); 208 | } 209 | ); 210 | 211 | return () => unsubscribe(); 212 | }, [usingFirestore, db, setLocalSettings]); 213 | 214 | useEffect(() => { 215 | if (!usingFirestore) { 216 | setSettings(localSettings); 217 | setLoading(false); 218 | } 219 | }, [usingFirestore, localSettings]); 220 | 221 | const updateSettings = useCallback( 222 | async (patch) => { 223 | let previousSettings = DEFAULT_SETTINGS; 224 | setSettings((prev) => { 225 | previousSettings = prev; 226 | return { ...prev, ...patch }; 227 | }); 228 | setLocalSettings((prev) => ({ ...prev, ...patch })); 229 | 230 | if (!usingFirestore) { 231 | return; 232 | } 233 | 234 | const ref = doc(db, SETTINGS_COLLECTION, SETTINGS_DOC_ID); 235 | try { 236 | await setDoc(ref, { ...patch, updatedAt: serverTimestamp() }, { merge: true }); 237 | } catch (err) { 238 | console.error('Failed to update settings', err); 239 | setSettings(previousSettings); 240 | setLocalSettings(previousSettings); 241 | throw err; 242 | } 243 | }, 244 | [db, setLocalSettings, usingFirestore] 245 | ); 246 | 247 | return { settings, updateSettings, loading, source: usingFirestore ? 'firestore' : 'local' }; 248 | } 249 | 250 | export default function FinanceTrackerApp() { 251 | const { transactions, add, remove, update, loading, source } = useTransactions(); 252 | const { settings, updateSettings: persistSettings } = useFinanceSettings(); 253 | 254 | const [queryText, setQueryText] = useState(''); 255 | const [typeFilter, setTypeFilter] = useState('all'); // 'all' | 'income' | 'expense' 256 | const [categoryFilter, setCategoryFilter] = useState('all'); 257 | const [startDate, setStartDate] = useState(daysAgo(30)); 258 | const [endDate, setEndDate] = useState(toISO(new Date())); 259 | const [addOpen, setAddOpen] = useState(false); 260 | const [hoverExport, setHoverExport] = useState(false); 261 | const [searchOpen, setSearchOpen] = useState(false); 262 | const [tempSearch, setTempSearch] = useState(''); 263 | const [chatOpen, setChatOpen] = useState(false); 264 | const [chatInput, setChatInput] = useState(''); 265 | const [chatMessages, setChatMessages] = useState([]); 266 | const [chatLoading, setChatLoading] = useState(false); 267 | const [editSavingsOpen, setEditSavingsOpen] = useState(false); 268 | const [editBudgetOpen, setEditBudgetOpen] = useState(false); 269 | 270 | const savingAmount = settings.savingAmount; 271 | const monthlyBudget = settings.monthlyBudget; 272 | 273 | const saveSavingAmount = useCallback( 274 | (value) => persistSettings({ savingAmount: value }), 275 | [persistSettings] 276 | ); 277 | 278 | const saveMonthlyBudget = useCallback( 279 | (value) => persistSettings({ monthlyBudget: value }), 280 | [persistSettings] 281 | ); 282 | 283 | const openSearchPrompt = () => { 284 | setTempSearch(queryText || ''); 285 | setSearchOpen(true); 286 | }; 287 | 288 | const filtered = useMemo(() => { 289 | return transactions.filter((t) => { 290 | const inType = typeFilter === 'all' || t.type === typeFilter; 291 | const inCategory = categoryFilter === 'all' || t.category === categoryFilter; 292 | const inText = !queryText || (t.note || '').toLowerCase().includes(queryText.toLowerCase()); 293 | const inDate = (!startDate || t.date >= startDate) && (!endDate || t.date <= endDate); 294 | return inType && inCategory && inText && inDate; 295 | }); 296 | }, [transactions, typeFilter, categoryFilter, queryText, startDate, endDate]); 297 | 298 | useEffect(() => { 299 | const onKeyDown = (e) => { 300 | const target = e.target; 301 | const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable); 302 | if (isInput) return; 303 | const key = (e.key || '').toLowerCase(); 304 | // Use Cmd+Shift+N (Mac) or Ctrl+Shift+N (Win/Linux) to avoid reserved Cmd+N/Ctrl+N 305 | if ((e.metaKey && e.shiftKey && key === 'n') || (e.ctrlKey && e.shiftKey && key === 'n')) { 306 | e.preventDefault(); 307 | setAddOpen(true); 308 | } 309 | }; 310 | window.addEventListener('keydown', onKeyDown); 311 | return () => window.removeEventListener('keydown', onKeyDown); 312 | }, []); 313 | 314 | const sums = useMemo(() => { 315 | let income = 0; 316 | let expense = 0; 317 | for (const t of filtered) { 318 | if (t.type === 'income') income += t.amount; 319 | else expense += t.amount; 320 | } 321 | return { income, expense, balance: income - expense }; 322 | }, [filtered]); 323 | const computedSavings = sums.balance; 324 | const savings = savingAmount == null ? computedSavings : savingAmount; 325 | const manualSavings = savingAmount != null; 326 | 327 | const currentMonth = useMemo(() => { 328 | const now = new Date(); 329 | return { year: now.getFullYear(), month: now.getMonth() }; 330 | }, []); 331 | 332 | const monthlyExpenses = useMemo(() => { 333 | const { year, month } = currentMonth; 334 | return transactions.reduce((total, t) => { 335 | if (t.type !== 'expense' || !t.date) return total; 336 | const d = new Date(t.date); 337 | if (Number.isNaN(d.getTime())) return total; 338 | if (d.getFullYear() === year && d.getMonth() === month) { 339 | return total + Number(t.amount || 0); 340 | } 341 | return total; 342 | }, 0); 343 | }, [transactions, currentMonth]); 344 | 345 | const budgetIsSet = monthlyBudget != null && monthlyBudget > 0; 346 | const budgetRemaining = budgetIsSet ? monthlyBudget - monthlyExpenses : null; 347 | const budgetSpentRatio = budgetIsSet && monthlyBudget > 0 ? Math.min(1, monthlyExpenses / monthlyBudget) : 0; 348 | const budgetProgressPercent = Math.min(100, Math.max(0, Math.round(budgetSpentRatio * 100))); 349 | const budgetStatusLabel = budgetIsSet 350 | ? budgetRemaining >= 0 351 | ? `${VND.format(Math.max(0, budgetRemaining))} remaining` 352 | : `${VND.format(Math.abs(budgetRemaining))} over budget` 353 | : 'Set a budget to start tracking'; 354 | const monthlyExpenseBalance = budgetIsSet ? budgetRemaining : null; 355 | const monthlyBalanceHighlight = monthlyExpenseBalance != null && monthlyExpenseBalance < 0 ? 'rose' : 'emerald'; 356 | const monthlyBalanceFootnote = budgetIsSet 357 | ? `${VND.format(monthlyExpenses)} spent of ${VND.format(monthlyBudget)}` 358 | : 'Set a budget to see remaining amount'; 359 | 360 | const chartDaily = useMemo(() => { 361 | const map = {}; 362 | const dates = []; 363 | for (let i = 29; i >= 0; i--) { 364 | const d = daysAgo(i); 365 | dates.push(d); 366 | map[d] = { date: d, income: 0, expense: 0, net: 0 }; 367 | } 368 | for (const t of filtered) { 369 | if (map[t.date]) { 370 | if (t.type === 'income') map[t.date].income += t.amount; 371 | else map[t.date].expense += t.amount; 372 | map[t.date].net = map[t.date].income - map[t.date].expense; 373 | } 374 | } 375 | return dates.map((d) => map[d]); 376 | }, [filtered]); 377 | 378 | const byCategory = useMemo(() => { 379 | const agg = {}; 380 | for (const t of filtered) { 381 | if (t.type !== 'expense') continue; 382 | const slot = (agg[t.category] ||= { category: t.category, expense: 0 }); 383 | slot.expense += t.amount; 384 | } 385 | return Object.values(agg) 386 | .sort((a, b) => b.expense - a.expense) 387 | .slice(0, 10); 388 | }, [filtered]); 389 | 390 | const pieData = useMemo(() => { 391 | const agg = {}; 392 | for (const t of filtered) { 393 | if (t.type === 'expense') agg[t.category] = (agg[t.category] || 0) + t.amount; 394 | } 395 | return Object.entries(agg).map(([name, value]) => ({ name, value })); 396 | }, [filtered]); 397 | 398 | const exportCSV = () => { 399 | const header = ['type', 'amount', 'category', 'note', 'date'].join(','); 400 | const rows = filtered 401 | .map((t) => [t.type, t.amount, t.category, (t.note || '').replace(/\n/g, ' '), t.date].join(',')) 402 | .join('\n'); 403 | const blob = new Blob([header + '\n' + rows], { type: 'text/csv;charset=utf-8;' }); 404 | const url = URL.createObjectURL(blob); 405 | const a = document.createElement('a'); 406 | a.href = url; 407 | a.download = `transactions_${startDate}_${endDate}.csv`; 408 | a.click(); 409 | URL.revokeObjectURL(url); 410 | }; 411 | 412 | async function askGemini(question) { 413 | try { 414 | setChatLoading(true); 415 | // Build a concise context from recent filtered transactions 416 | const recent = filtered 417 | .slice(0, 50) 418 | .map((t) => `${t.date} | ${t.type} | ${t.category} | ${VND.format(t.amount)} | ${(t.note || '').slice(0, 60)}`) 419 | .join('\n'); 420 | const prompt = `${question}\n\nRecent transactions (most recent first):\n${recent}\n\nAnswer briefly.`; 421 | const endpoint = 422 | 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=' + 423 | encodeURIComponent('AIzaSyCKRdC8c0d_99SG3BvyJfhiI7lLsHZY-Lg'); 424 | const res = await fetch(endpoint, { 425 | method: 'POST', 426 | headers: { 'Content-Type': 'application/json' }, 427 | body: JSON.stringify({ 428 | contents: [{ role: 'user', parts: [{ text: prompt }] }], 429 | generationConfig: { temperature: 0.3, topK: 40, topP: 0.95, maxOutputTokens: 512 }, 430 | }), 431 | }); 432 | const data = await res.json(); 433 | if (!res.ok) { 434 | const message = data?.error?.message || `HTTP ${res.status}`; 435 | throw new Error(message); 436 | } 437 | const parts = data?.candidates?.[0]?.content?.parts || []; 438 | const text = parts.map((p) => p.text).filter(Boolean).join('\n').trim(); 439 | return text || 'Sorry, the model returned an empty result.'; 440 | } catch (e) { 441 | console.error('Gemini error:', e); 442 | return `Error: ${e.message || 'request failed'}`; 443 | } finally { 444 | setChatLoading(false); 445 | } 446 | } 447 | 448 | return ( 449 |
450 |
451 |
452 |
453 |
454 | 455 | 456 | 457 |

VND Finance Tracker

458 | 459 | {source === 'firestore' ? 'Synced' : 'Local'} 460 | 461 |
462 |
463 | 464 | 465 | 466 | 467 | 475 | 483 | 493 |
494 |
495 |
496 |
497 | 498 |
499 |
500 | {/* Filters - single row, full width */} 501 | 506 | 513 | 522 | 523 | setStartDate(e.target.value)} 527 | style={{ width: 160, borderRadius: 12, padding: 10, border: '1px solid #DBE2EF', background: '#F9F7F7', flexShrink: 0 }} 528 | /> 529 | setEndDate(e.target.value)} 533 | style={{ width: 160, borderRadius: 12, padding: 10, border: '1px solid #DBE2EF', background: '#F9F7F7', flexShrink: 0 }} 534 | /> 535 | 536 | 537 | {/* Search popup */} 538 | setSearchOpen(false)} title="Search transactions"> 539 |
{ 541 | e.preventDefault(); 542 | setQueryText(tempSearch); 543 | setSearchOpen(false); 544 | }} 545 | style={{ display: 'flex', flexDirection: 'column', gap: 12 }} 546 | > 547 | setTempSearch(e.target.value)} 551 | placeholder="Type to search transactions..." 552 | style={{ padding: 10, borderRadius: 8, border: '1px solid #DBE2EF', background: '#F9F7F7' }} 553 | /> 554 |
555 | 566 | 569 |
570 |
571 |
572 | 573 | {/* Stats */} 574 | 575 | } 579 | highlight="emerald" 580 | action={ 581 | 588 | } 589 | footnote={budgetStatusLabel} 590 | > 591 |
592 |
593 |
602 |
603 |
604 | Spent {VND.format(monthlyExpenses)} 605 | {budgetIsSet ? `${budgetProgressPercent}%` : '0%'} 606 |
607 |
608 | 609 | } 613 | highlight="indigo" 614 | action={ 615 | 622 | } 623 | footnote={manualSavings ? 'Manual value' : 'Auto from income - expenses'} 624 | /> 625 | } highlight="rose" /> 626 | } 630 | highlight={monthlyBalanceHighlight} 631 | footnote={monthlyBalanceFootnote} 632 | /> 633 | 634 | 635 | {/* Charts */} 636 | 637 | 638 |
639 |
30-day cash flow
640 |
641 |
642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | VND.format(v).replace('₫', '')} width={80} tick={{ fontSize: 12 }} /> 657 | VND.format(Number(v))} /> 658 | 659 | 660 | 661 | 662 | 663 |
664 |
665 | 666 | 667 |
668 |
Expense share by category
669 |
670 |
671 | 672 | 673 | 674 | {pieData.map((_, i) => ( 675 | 676 | ))} 677 | 678 | VND.format(Number(v))} /> 679 | 680 | 681 | 682 |
683 |
684 |
685 | 686 | 687 | 688 |
689 |
Transactions
690 | {filtered.length} records 691 |
692 |
693 | 694 |
695 |
696 | 697 | 698 |
699 |
Top categories
700 |
701 |
702 | 703 | 704 | 705 | 706 | VND.format(v).replace('₫', '')} width={80} tick={{ fontSize: 12 }} /> 707 | VND.format(Number(v))} /> 708 | 709 | 710 | 711 | 712 |
713 |
714 |
715 | 716 | setChatOpen((v) => !v)} 719 | messages={chatMessages} 720 | input={chatInput} 721 | setInput={setChatInput} 722 | loading={chatLoading} 723 | onSend={async (q) => { 724 | setChatMessages((m) => [...m, { role: 'user', text: q }]); 725 | setChatInput(''); 726 | const a = await askGemini(q); 727 | setChatMessages((m) => [...m, { role: 'model', text: a }]); 728 | }} 729 | /> 730 |
731 |
732 | 739 | 746 |
747 | ); 748 | } 749 | 750 | function Card({ children, style }) { 751 | return ( 752 |
762 | {children} 763 |
764 | ); 765 | } 766 | 767 | function StatCard({ title, value, icon, highlight, action, footnote, children }) { 768 | const chipColors = 769 | highlight === 'indigo' 770 | ? { bg: 'rgba(63,114,175,0.15)', fg: '#112D4E', accent: '#3F72AF' } 771 | : highlight === 'emerald' 772 | ? { bg: 'rgba(16,185,129,0.15)', fg: '#064E3B', accent: '#059669' } 773 | : highlight === 'rose' 774 | ? { bg: 'rgba(239,68,68,0.16)', fg: '#7F1D1D', accent: '#ef4444' } 775 | : { bg: 'rgba(17,45,78,0.15)', fg: '#112D4E', accent: '#112D4E' }; 776 | 777 | return ( 778 | 779 | 780 |
781 |
782 |
{icon}
783 | {action ?
{action}
: null} 784 |
785 |
{title}
786 |
{value}
787 |
788 | {footnote ?
{footnote}
: null} 789 | {children} 790 |
791 |
792 |
793 |
794 | ); 795 | } 796 | function TypingDots() { 797 | return ( 798 | 799 | 800 | 801 | 802 | 803 | 804 | ); 805 | } 806 | 807 | function ChatBubble({ open, onToggle, messages, onSend, input, setInput, loading }) { 808 | const listRef = useRef(null); 809 | useEffect(() => { 810 | if (open && listRef.current) { 811 | listRef.current.scrollTop = listRef.current.scrollHeight; 812 | } 813 | }, [open, messages, loading]); 814 | return ( 815 |
816 | {open && ( 817 |
818 |
819 |
820 | 821 | Chat Assistant 822 |
823 | 824 |
825 |
826 | {messages.length === 0 && ( 827 |
Ask about your spending, categories, or recent transactions.
828 | )} 829 | {messages.map((m, i) => ( 830 |
831 | {m.text} 832 |
833 | ))} 834 | {loading && ( 835 |
836 | 837 |
838 | )} 839 |
840 |
{ 842 | e.preventDefault(); 843 | if (!input.trim() || loading) return; 844 | onSend(input.trim()); 845 | }} 846 | style={{ display: 'flex', gap: 6, padding: 10, borderTop: '1px solid #DBE2EF' }} 847 | > 848 | setInput(e.target.value)} placeholder="Type your question..." style={{ flex: 1, padding: 8, borderRadius: 10, border: '1px solid #DBE2EF', background: '#fff', fontSize: 12 }} /> 849 | 852 |
853 |
854 | )} 855 | 858 |
859 | ); 860 | } 861 | 862 | function CategorySelect({ value, onChange, style }) { 863 | const all = ['all', ...DEFAULT_INCOME_CATS, ...DEFAULT_EXPENSE_CATS]; 864 | return ( 865 | 872 | ); 873 | } 874 | 875 | function TransactionTable({ items, onDelete, onEdit }) { 876 | const [editingId, setEditingId] = useState(null); 877 | const [buffer, setBuffer] = useState({}); 878 | 879 | return ( 880 |
881 |
882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | 891 | 892 | 893 | 894 | 895 | {items.map((t) => ( 896 | 897 | 898 | 903 | 904 | 905 | 908 | 918 | 919 | ))} 920 | 921 | 922 |
DateTypeCategoryAmountNoteActions
{t.date} 899 | 900 | {t.type === 'income' ? 'Income' : 'Expense'} 901 | 902 | {t.category}{VND.format(t.amount)} 906 |
{t.note}
907 |
909 |
910 | 913 | 916 |
917 |
923 |
924 | 925 | { setEditingId(null); setBuffer({}); }} title="Edit transaction"> 926 | { 930 | if (!editingId) return; 931 | onEdit(editingId, patch); 932 | setEditingId(null); 933 | setBuffer({}); 934 | }} 935 | /> 936 | 937 |
938 | ); 939 | } 940 | 941 | function AddTransactionModal({ onAdd, open: controlledOpen, onOpenChange, compact }) { 942 | const isControlled = typeof controlledOpen === 'boolean' && typeof onOpenChange === 'function'; 943 | const [internalOpen, setInternalOpen] = useState(false); 944 | const open = isControlled ? controlledOpen : internalOpen; 945 | const setOpen = isControlled ? onOpenChange : setInternalOpen; 946 | const [hoverAddTx, setHoverAddTx] = useState(false); 947 | return ( 948 | <> 949 | 967 | setOpen(false)} title="New transaction"> 968 | { 970 | onAdd(tx); 971 | setOpen(false); 972 | }} 973 | /> 974 | 975 | 976 | ); 977 | } 978 | 979 | function AddExpenseModal({ onAdd, compact }) { 980 | const [open, setOpen] = useState(false); 981 | const [hoverAddExpense, setHoverAddExpense] = useState(false); 982 | return ( 983 | <> 984 | 1002 | setOpen(false)} title="Add expense"> 1003 | { 1007 | onAdd(tx); 1008 | setOpen(false); 1009 | }} 1010 | /> 1011 | 1012 | 1013 | ); 1014 | } 1015 | 1016 | function AddSavingModal({ onAdd, compact }) { 1017 | const [open, setOpen] = useState(false); 1018 | const [hoverAddSaving, setHoverAddSaving] = useState(false); 1019 | return ( 1020 | <> 1021 | 1039 | setOpen(false)} title="Add saving"> 1040 | { 1044 | onAdd(tx); 1045 | setOpen(false); 1046 | }} 1047 | /> 1048 | 1049 | 1050 | ); 1051 | } 1052 | 1053 | function EditMonthlyBudgetModal({ open, onOpenChange, value, onSave, spent }) { 1054 | const [input, setInput] = useState(''); 1055 | const [submitting, setSubmitting] = useState(false); 1056 | 1057 | useEffect(() => { 1058 | if (!open) return; 1059 | setInput(value != null ? String(Math.round(value)) : ''); 1060 | }, [open, value]); 1061 | 1062 | const handleSubmit = async (e) => { 1063 | e.preventDefault(); 1064 | const next = Number(input); 1065 | if (!Number.isFinite(next) || next < 0) { 1066 | alert('Enter a valid non-negative number'); 1067 | return; 1068 | } 1069 | try { 1070 | setSubmitting(true); 1071 | await onSave(next); 1072 | onOpenChange(false); 1073 | } catch (err) { 1074 | console.error('Failed to save monthly budget', err); 1075 | alert('Could not save the monthly budget. Please try again.'); 1076 | } finally { 1077 | setSubmitting(false); 1078 | } 1079 | }; 1080 | 1081 | const handleClear = async () => { 1082 | try { 1083 | setSubmitting(true); 1084 | await onSave(null); 1085 | onOpenChange(false); 1086 | } catch (err) { 1087 | console.error('Failed to clear monthly budget', err); 1088 | alert('Could not clear the monthly budget. Please try again.'); 1089 | } finally { 1090 | setSubmitting(false); 1091 | } 1092 | }; 1093 | 1094 | return ( 1095 | onOpenChange(false)} title="Edit monthly expense budget"> 1096 |
1097 | 1098 | setInput(e.target.value.replace(/[^0-9]/g, ''))} 1102 | placeholder="e.g., 8000000" 1103 | style={{ padding: 10, borderRadius: 8, border: '1px solid #DBE2EF', background: '#F9F7F7' }} 1104 | /> 1105 |
{input ? VND.format(Number(input)) : ''}
1106 |
Expenses recorded this month: {VND.format(spent)}
1107 |
1108 | 1111 | 1114 |
1115 |
1116 |
1117 | ); 1118 | } 1119 | 1120 | function EditSavingModal({ open, onOpenChange, value, onSave, computedSavings }) { 1121 | const [input, setInput] = useState(''); 1122 | const [submitting, setSubmitting] = useState(false); 1123 | 1124 | useEffect(() => { 1125 | if (!open) return; 1126 | const base = value != null ? value : computedSavings; 1127 | setInput(base != null ? String(Math.round(base)) : ''); 1128 | }, [open, value, computedSavings]); 1129 | 1130 | const handleSubmit = async (e) => { 1131 | e.preventDefault(); 1132 | const next = Number(input); 1133 | if (!Number.isFinite(next) || next < 0) { 1134 | alert('Enter a valid non-negative number'); 1135 | return; 1136 | } 1137 | try { 1138 | setSubmitting(true); 1139 | await onSave(next); 1140 | onOpenChange(false); 1141 | } catch (err) { 1142 | console.error('Failed to save savings amount', err); 1143 | alert('Could not save the savings amount. Please try again.'); 1144 | } finally { 1145 | setSubmitting(false); 1146 | } 1147 | }; 1148 | 1149 | const handleReset = async () => { 1150 | try { 1151 | setSubmitting(true); 1152 | await onSave(null); 1153 | onOpenChange(false); 1154 | } catch (err) { 1155 | console.error('Failed to reset savings amount', err); 1156 | alert('Could not reset the savings amount. Please try again.'); 1157 | } finally { 1158 | setSubmitting(false); 1159 | } 1160 | }; 1161 | 1162 | return ( 1163 | onOpenChange(false)} title="Edit savings amount"> 1164 |
1165 | 1166 | setInput(e.target.value.replace(/[^0-9]/g, ''))} 1170 | placeholder="e.g., 15000000" 1171 | style={{ padding: 10, borderRadius: 8, border: '1px solid #DBE2EF', background: '#F9F7F7' }} 1172 | /> 1173 |
{input ? VND.format(Number(input)) : ''}
1174 |
Automatic value from income - expenses: {VND.format(computedSavings)}
1175 |
1176 | 1179 | 1182 |
1183 |
1184 |
1185 | ); 1186 | } 1187 | 1188 | function AddSalaryModal({ onAdd, compact }) { 1189 | const [open, setOpen] = useState(false); 1190 | const [hoverAddSalary, setHoverAddSalary] = useState(false); 1191 | return ( 1192 | <> 1193 | 1211 | setOpen(false)} title="Add salary"> 1212 | { 1216 | onAdd(tx); 1217 | setOpen(false); 1218 | }} 1219 | /> 1220 | 1221 | 1222 | ); 1223 | } 1224 | 1225 | function TxForm({ initial, onSubmit, submitLabel = 'Add' }) { 1226 | const [type, setType] = useState((initial?.type) || 'expense'); 1227 | const [amount, setAmount] = useState(initial?.amount != null ? String(initial.amount) : ''); 1228 | const [category, setCategory] = useState(initial?.category || DEFAULT_EXPENSE_CATS[0]); 1229 | const [note, setNote] = useState(initial?.note || ''); 1230 | const [date, setDate] = useState(initial?.date || toISO(new Date())); 1231 | 1232 | useEffect(() => { 1233 | if (initial?.type) setType(initial.type); 1234 | if (initial?.amount != null) setAmount(String(initial.amount)); 1235 | if (initial?.category) setCategory(initial.category); 1236 | if (initial?.note != null) setNote(initial.note); 1237 | if (initial?.date) setDate(initial.date); 1238 | }, [initial]); 1239 | 1240 | useEffect(() => { 1241 | if (!initial?.category) { 1242 | setCategory(type === 'income' ? DEFAULT_INCOME_CATS[0] : DEFAULT_EXPENSE_CATS[0]); 1243 | } 1244 | }, [type]); 1245 | 1246 | const categories = type === 'income' ? DEFAULT_INCOME_CATS : DEFAULT_EXPENSE_CATS; 1247 | 1248 | return ( 1249 |
{ 1251 | e.preventDefault(); 1252 | const amt = Number(amount); 1253 | if (!amt || amt <= 0) { 1254 | alert('Invalid amount'); 1255 | return; 1256 | } 1257 | const payload = { type, amount: amt, category, note, date }; 1258 | onSubmit(payload); 1259 | }} 1260 | style={{ display: 'flex', flexDirection: 'column', gap: 12 }} 1261 | > 1262 |
1263 | 1266 | 1269 |
1270 | 1271 |
1272 |
1273 | 1274 | setAmount(e.target.value.replace(/[^\d]/g, ''))} placeholder="e.g., 250000" style={{ padding: 10, borderRadius: 8, border: '1px solid #DBE2EF', background: '#F9F7F7' }} /> 1275 |
{amount ? VND.format(Number(amount)) : ''}
1276 |
1277 |
1278 | 1279 | 1286 |
1287 |
1288 | 1289 |
1290 |
1291 | 1292 | setDate(e.target.value)} style={{ padding: 10, borderRadius: 8, border: '1px solid #DBE2EF', background: '#F9F7F7' }} /> 1293 |
1294 |
1295 | 1296 | setNote(e.target.value)} placeholder="e.g., Coffee, electricity, bonus..." style={{ padding: 10, borderRadius: 8, border: '1px solid #DBE2EF', background: '#F9F7F7' }} /> 1297 |
1298 |
1299 | 1300 |
1301 | 1302 |
1303 |
1304 | ); 1305 | } 1306 | 1307 | function Modal({ open, onClose, title, children }) { 1308 | if (!open) return null; 1309 | return ( 1310 |
1326 |
e.stopPropagation()} 1339 | > 1340 |
{title}
1341 |
{children}
1342 |
1343 |
1344 | ); 1345 | } 1346 | --------------------------------------------------------------------------------