feat: initial BudgetWise budgeting app with Appwrite sync
Full-stack PWA budgeting app installable on Android via Chrome. Features implemented: - Monthly balance sheet: income sources (monthly/yearly), buffer (fixed $ or % of income) - Buckets: regular, savings-goal, and investment types with custom goals (amount or % of income, monthly or yearly schedule) - Debt tracker: manual or auto-EMI (amortization formula), interest rate, remaining balance tracking, avalanche-method repayment suggestions - Loan calculator: max affordable principal and EMI checker based on remaining income after all expenses - Investment projections: compound interest at monthly/yearly rate, 1/5/10-year projections per bucket - Deposit / withdraw transactions per bucket with balance history - Appwrite self-hosted backend: auth, 5 collections (balance_sheets, incomes, buckets, debts, transactions) - scripts/setup-appwrite.cjs: one-command DB setup via node-appwrite - PWA manifest + service worker (vite-plugin-pwa) for Android install - Dark mobile-first UI with TailwindCSS, Zustand state, React Router v6 https://claude.ai/code/session_01Ny2EMaZYvzk5SSVDAPgxpP
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Appwrite Configuration
|
||||||
|
# Copy this to .env and fill in your self-hosted Appwrite details
|
||||||
|
|
||||||
|
VITE_APPWRITE_ENDPOINT=https://your-appwrite-instance.com/v1
|
||||||
|
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||||
|
|
||||||
|
# Used only by the setup script (scripts/setup-appwrite.cjs)
|
||||||
|
# NEVER expose this in the browser
|
||||||
|
APPWRITE_API_KEY=your-api-key-with-databases-write-permission
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
coverage
|
||||||
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# BudgetWise
|
||||||
|
|
||||||
|
A personal budgeting PWA with self-hosted [Appwrite](https://appwrite.io) sync — installable as an Android app.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Details |
|
||||||
|
|---|---|
|
||||||
|
| **Monthly balance sheet** | Track income sources (monthly or yearly), set a monthly buffer (fixed amount or % of income) |
|
||||||
|
| **Buckets** | Regular, savings-goal, and investment buckets with custom goals (amount or % of income, monthly or yearly) |
|
||||||
|
| **Investment returns** | Track projected returns at a monthly or annual rate, see 1/5/10-year projections |
|
||||||
|
| **Debt tracker** | Manual or auto-EMI calculation, interest rate (monthly/yearly), amortization tracking |
|
||||||
|
| **Auto repayment plan** | After all expenses, avalanche-method suggestion for extra debt repayment |
|
||||||
|
| **Loan calculator** | Max affordable loan based on remaining income after buffer + buckets + debts |
|
||||||
|
| **Deposit / Withdraw** | Manual transactions per bucket, balance history |
|
||||||
|
| **PWA / Android** | Installable via Chrome "Add to Home Screen" |
|
||||||
|
| **Offline capable** | Service worker caches the app shell |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **React 18** + **TypeScript** + **Vite**
|
||||||
|
- **TailwindCSS** (dark theme, mobile-first)
|
||||||
|
- **Appwrite JS SDK** (self-hosted database & auth)
|
||||||
|
- **Zustand** (state management)
|
||||||
|
- **vite-plugin-pwa** (service worker + web manifest)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Clone & install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd budget
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_APPWRITE_ENDPOINT=https://your-appwrite.example.com/v1
|
||||||
|
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||||
|
APPWRITE_API_KEY=your-server-api-key # only for setup script
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set up Appwrite
|
||||||
|
|
||||||
|
Install Appwrite (Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
|
||||||
|
--entrypoint="install" \
|
||||||
|
appwrite/appwrite:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
1. Open your Appwrite console → **Create Project** → note the **Project ID**
|
||||||
|
2. Go to **API Keys** → create a key with `databases.write` permission
|
||||||
|
3. Add the key to `.env` as `APPWRITE_API_KEY`
|
||||||
|
|
||||||
|
### 4. Create database collections
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup:appwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `budget_db` database with all five collections and their attributes/indexes.
|
||||||
|
|
||||||
|
### 5. Build & deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Deploy dist/ to any static host (Nginx, Caddy, Netlify, Vercel…)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **HTTPS is required** for the PWA install prompt and service worker.
|
||||||
|
|
||||||
|
### 6. Install on Android
|
||||||
|
|
||||||
|
1. Open the app URL in **Chrome on Android**
|
||||||
|
2. Tap the **three-dot menu** → **Add to Home Screen**
|
||||||
|
3. The app now runs as a standalone PWA with its own icon and no browser chrome
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
balance_sheets month, year, buffer_type, buffer_value
|
||||||
|
incomes balance_sheet_id, name, amount, frequency
|
||||||
|
buckets name, type, current_balance, goal_*, return_*, color
|
||||||
|
debts name, principal, remaining_balance, interest_rate, term_months, monthly_payment
|
||||||
|
transactions bucket_id, type, amount, date, notes, balance_after
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key calculations
|
||||||
|
|
||||||
|
| Formula | Where used |
|
||||||
|
|---|---|
|
||||||
|
| Monthly income | Sum of incomes (yearly ones ÷ 12) |
|
||||||
|
| Buffer | Fixed $ or % × monthly income |
|
||||||
|
| Bucket allocation | Goal amount or % of income, divided by 12 for yearly goals |
|
||||||
|
| EMI | `P × r × (1+r)^n / ((1+r)^n − 1)` |
|
||||||
|
| Max loan | `payment × (1 − (1+r)^−n) / r` |
|
||||||
|
| Investment projection | Compound interest `P × (1+r)^n` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server at http://localhost:5173
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build locally
|
||||||
|
```
|
||||||
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<meta name="description" content="BudgetWise – personal budgeting app with Appwrite sync" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="BudgetWise" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>BudgetWise</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7127
package-lock.json
generated
Normal file
7127
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "budgetwise",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"setup:appwrite": "node scripts/setup-appwrite.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"appwrite": "^16.0.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"node-appwrite": "^12.0.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.1.0",
|
||||||
|
"vite-plugin-pwa": "^0.19.1",
|
||||||
|
"workbox-window": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
8
public/icon.svg
Normal file
8
public/icon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="120" fill="#0f172a"/>
|
||||||
|
<rect x="40" y="40" width="432" height="432" rx="90" fill="#1e293b"/>
|
||||||
|
<text x="256" y="340" font-family="system-ui, -apple-system, sans-serif" font-size="240" font-weight="700" text-anchor="middle" fill="#6366f1">B</text>
|
||||||
|
<!-- Dollar sign hint -->
|
||||||
|
<circle cx="340" cy="160" r="48" fill="#22c55e" opacity="0.9"/>
|
||||||
|
<text x="340" y="178" font-family="system-ui" font-size="52" font-weight="700" text-anchor="middle" fill="white">$</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 573 B |
245
scripts/setup-appwrite.cjs
Normal file
245
scripts/setup-appwrite.cjs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* BudgetWise – Appwrite database setup script
|
||||||
|
*
|
||||||
|
* Run once to create the database, collections, and attributes
|
||||||
|
* in your self-hosted Appwrite instance.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* cp .env.example .env
|
||||||
|
* # Fill in VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, APPWRITE_API_KEY
|
||||||
|
* npm run setup:appwrite
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Client, Databases, Permission, Role, IndexType } = require('node-appwrite');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ── Load .env manually (no dotenv dependency needed) ─────────────────────────
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = path.join(__dirname, '..', '.env');
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
console.error('❌ .env file not found. Copy .env.example to .env and fill in the values.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
|
||||||
|
const env = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const [key, ...rest] = trimmed.split('=');
|
||||||
|
env[key.trim()] = rest.join('=').trim();
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
|
||||||
|
const ENDPOINT = env.VITE_APPWRITE_ENDPOINT;
|
||||||
|
const PROJECT_ID = env.VITE_APPWRITE_PROJECT_ID;
|
||||||
|
const API_KEY = env.APPWRITE_API_KEY;
|
||||||
|
|
||||||
|
if (!ENDPOINT || !PROJECT_ID || !API_KEY) {
|
||||||
|
console.error('❌ Missing VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, or APPWRITE_API_KEY in .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(ENDPOINT)
|
||||||
|
.setProject(PROJECT_ID)
|
||||||
|
.setKey(API_KEY);
|
||||||
|
|
||||||
|
const databases = new Databases(client);
|
||||||
|
|
||||||
|
const DB_ID = 'budget_db';
|
||||||
|
const DB_NAME = 'BudgetWise';
|
||||||
|
|
||||||
|
// ── Permissions: only authenticated users can access their own docs ──────────
|
||||||
|
// We rely on document-level security with user_id filtering.
|
||||||
|
const PERMISSIONS = [
|
||||||
|
Permission.read(Role.users()),
|
||||||
|
Permission.create(Role.users()),
|
||||||
|
Permission.update(Role.users()),
|
||||||
|
Permission.delete(Role.users()),
|
||||||
|
];
|
||||||
|
|
||||||
|
async function createCollection(id, name, attrs, indexes = []) {
|
||||||
|
console.log(`\n📁 Creating collection: ${name}`);
|
||||||
|
try {
|
||||||
|
await databases.createCollection(DB_ID, id, name, PERMISSIONS);
|
||||||
|
console.log(` ✓ Collection created`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 409) {
|
||||||
|
console.log(` ⚠ Collection already exists, skipping...`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add attributes
|
||||||
|
for (const attr of attrs) {
|
||||||
|
try {
|
||||||
|
if (attr.type === 'string') {
|
||||||
|
await databases.createStringAttribute(DB_ID, id, attr.key, attr.size ?? 255, attr.required ?? false, attr.default ?? null);
|
||||||
|
} else if (attr.type === 'float') {
|
||||||
|
await databases.createFloatAttribute(DB_ID, id, attr.key, attr.required ?? false, undefined, undefined, attr.default ?? null);
|
||||||
|
} else if (attr.type === 'integer') {
|
||||||
|
await databases.createIntegerAttribute(DB_ID, id, attr.key, attr.required ?? false, undefined, undefined, attr.default ?? null);
|
||||||
|
} else if (attr.type === 'boolean') {
|
||||||
|
await databases.createBooleanAttribute(DB_ID, id, attr.key, attr.required ?? false, attr.default ?? null);
|
||||||
|
}
|
||||||
|
process.stdout.write(` ✓ ${attr.key}\n`);
|
||||||
|
// Wait a bit between attributes (Appwrite rate limits)
|
||||||
|
await sleep(300);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 409) {
|
||||||
|
process.stdout.write(` ⚠ ${attr.key} (already exists)\n`);
|
||||||
|
} else {
|
||||||
|
console.error(` ❌ ${attr.key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
for (const idx of indexes) {
|
||||||
|
try {
|
||||||
|
await databases.createIndex(DB_ID, id, idx.key, IndexType.Key, idx.attributes, idx.orders ?? []);
|
||||||
|
process.stdout.write(` ✓ index: ${idx.key}\n`);
|
||||||
|
await sleep(300);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 409) {
|
||||||
|
process.stdout.write(` ⚠ index ${idx.key} (already exists)\n`);
|
||||||
|
} else {
|
||||||
|
console.error(` ❌ index ${idx.key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🚀 BudgetWise Appwrite Setup');
|
||||||
|
console.log(` Endpoint : ${ENDPOINT}`);
|
||||||
|
console.log(` Project : ${PROJECT_ID}`);
|
||||||
|
|
||||||
|
// Create database
|
||||||
|
console.log('\n🗄 Creating database...');
|
||||||
|
try {
|
||||||
|
await databases.create(DB_ID, DB_NAME);
|
||||||
|
console.log(' ✓ Database created');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 409) {
|
||||||
|
console.log(' ⚠ Database already exists, continuing...');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── balance_sheets ────────────────────────────────────────────────────────
|
||||||
|
await createCollection(
|
||||||
|
'balance_sheets',
|
||||||
|
'Balance Sheets',
|
||||||
|
[
|
||||||
|
{ type: 'integer', key: 'month', required: true },
|
||||||
|
{ type: 'integer', key: 'year', required: true },
|
||||||
|
{ type: 'string', key: 'buffer_type', required: true, size: 20 },
|
||||||
|
{ type: 'float', key: 'buffer_value', required: true, default: 0 },
|
||||||
|
{ type: 'string', key: 'user_id', required: true, size: 64 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'user_month_year', attributes: ['user_id', 'month', 'year'], orders: ['ASC', 'DESC', 'DESC'] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── incomes ───────────────────────────────────────────────────────────────
|
||||||
|
await createCollection(
|
||||||
|
'incomes',
|
||||||
|
'Incomes',
|
||||||
|
[
|
||||||
|
{ type: 'string', key: 'balance_sheet_id', required: true, size: 64 },
|
||||||
|
{ type: 'string', key: 'name', required: true, size: 128 },
|
||||||
|
{ type: 'float', key: 'amount', required: true },
|
||||||
|
{ type: 'string', key: 'frequency', required: true, size: 20 },
|
||||||
|
{ type: 'string', key: 'user_id', required: true, size: 64 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'user_sheet', attributes: ['user_id', 'balance_sheet_id'] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── buckets ───────────────────────────────────────────────────────────────
|
||||||
|
await createCollection(
|
||||||
|
'buckets',
|
||||||
|
'Buckets',
|
||||||
|
[
|
||||||
|
{ type: 'string', key: 'name', required: true, size: 128 },
|
||||||
|
{ type: 'string', key: 'description', required: false, size: 512, default: '' },
|
||||||
|
{ type: 'string', key: 'type', required: true, size: 32 },
|
||||||
|
{ type: 'float', key: 'current_balance', required: true, default: 0 },
|
||||||
|
{ type: 'float', key: 'goal_amount', required: false, default: 0 },
|
||||||
|
{ type: 'string', key: 'goal_type', required: false, size: 20, default: 'amount' },
|
||||||
|
{ type: 'string', key: 'goal_frequency', required: false, size: 20, default: 'monthly' },
|
||||||
|
{ type: 'float', key: 'return_percent', required: false, default: 0 },
|
||||||
|
{ type: 'string', key: 'return_frequency', required: false, size: 20, default: 'yearly' },
|
||||||
|
{ type: 'string', key: 'color', required: true, size: 16 },
|
||||||
|
{ type: 'integer', key: 'sort_order', required: false, default: 0 },
|
||||||
|
{ type: 'string', key: 'user_id', required: true, size: 64 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'user_order', attributes: ['user_id', 'sort_order'], orders: ['ASC', 'ASC'] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── debts ─────────────────────────────────────────────────────────────────
|
||||||
|
await createCollection(
|
||||||
|
'debts',
|
||||||
|
'Debts',
|
||||||
|
[
|
||||||
|
{ type: 'string', key: 'name', required: true, size: 128 },
|
||||||
|
{ type: 'float', key: 'principal', required: true },
|
||||||
|
{ type: 'float', key: 'remaining_balance', required: true },
|
||||||
|
{ type: 'float', key: 'interest_rate', required: true },
|
||||||
|
{ type: 'string', key: 'interest_frequency', required: true, size: 20 },
|
||||||
|
{ type: 'integer', key: 'term_months', required: true },
|
||||||
|
{ type: 'float', key: 'monthly_payment', required: true },
|
||||||
|
{ type: 'boolean', key: 'is_auto_calculated', required: true, default: false },
|
||||||
|
{ type: 'string', key: 'start_date', required: true, size: 24 },
|
||||||
|
{ type: 'string', key: 'user_id', required: true, size: 64 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'user_id_idx', attributes: ['user_id'] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── transactions ──────────────────────────────────────────────────────────
|
||||||
|
await createCollection(
|
||||||
|
'transactions',
|
||||||
|
'Transactions',
|
||||||
|
[
|
||||||
|
{ type: 'string', key: 'bucket_id', required: true, size: 64 },
|
||||||
|
{ type: 'string', key: 'type', required: true, size: 20 },
|
||||||
|
{ type: 'float', key: 'amount', required: true },
|
||||||
|
{ type: 'string', key: 'date', required: true, size: 24 },
|
||||||
|
{ type: 'string', key: 'notes', required: false, size: 512, default: '' },
|
||||||
|
{ type: 'float', key: 'balance_after', required: true },
|
||||||
|
{ type: 'string', key: 'user_id', required: true, size: 64 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'user_bucket_date', attributes: ['user_id', 'bucket_id', 'date'], orders: ['ASC', 'ASC', 'DESC'] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n\n✅ Setup complete!');
|
||||||
|
console.log('\nNext steps:');
|
||||||
|
console.log(' 1. npm run build');
|
||||||
|
console.log(' 2. Deploy the dist/ folder to your web server');
|
||||||
|
console.log(' 3. Open the app in Chrome on Android → "Add to Home Screen"');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('\n❌ Setup failed:', err.message ?? err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
116
src/App.tsx
Normal file
116
src/App.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { getCurrentUser } from './appwrite/auth';
|
||||||
|
import { useStore } from './store';
|
||||||
|
import { Auth } from './pages/Auth';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { BalanceSheet } from './pages/BalanceSheet';
|
||||||
|
import { Buckets } from './pages/Buckets';
|
||||||
|
import { BucketDetail } from './pages/BucketDetail';
|
||||||
|
import { Debts } from './pages/Debts';
|
||||||
|
import { LoanCalculator } from './pages/LoanCalculator';
|
||||||
|
import { More } from './pages/More';
|
||||||
|
import { Settings } from './pages/Settings';
|
||||||
|
|
||||||
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useStore((s) => s.user);
|
||||||
|
return user ? <>{children}</> : <Navigate to="/auth" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { user, setUser } = useStore();
|
||||||
|
const [booting, setBooting] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentUser().then((u) => {
|
||||||
|
setUser(u);
|
||||||
|
setBooting(false);
|
||||||
|
});
|
||||||
|
}, [setUser]);
|
||||||
|
|
||||||
|
if (booting) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-overlay flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-accent rounded-2xl flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-white">B</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth" element={user ? <Navigate to="/" replace /> : <Auth />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Dashboard />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/balance-sheet"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<BalanceSheet />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buckets"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Buckets />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buckets/:id"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<BucketDetail />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/debts"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Debts />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/loan-calculator"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<LoanCalculator />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/more"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<More />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Settings />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/appwrite/auth.ts
Normal file
23
src/appwrite/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ID } from 'appwrite';
|
||||||
|
import { account } from './config';
|
||||||
|
|
||||||
|
export async function register(email: string, password: string, name: string) {
|
||||||
|
await account.create(ID.unique(), email, password, name);
|
||||||
|
return login(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
return account.createEmailPasswordSession(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
return account.deleteSession('current');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
try {
|
||||||
|
return await account.get();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/appwrite/config.ts
Normal file
28
src/appwrite/config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Client, Account, Databases } from 'appwrite';
|
||||||
|
|
||||||
|
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT as string;
|
||||||
|
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID as string;
|
||||||
|
|
||||||
|
if (!endpoint || !projectId) {
|
||||||
|
console.warn(
|
||||||
|
'[BudgetWise] Missing Appwrite config. Set VITE_APPWRITE_ENDPOINT and VITE_APPWRITE_PROJECT_ID in .env',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = new Client()
|
||||||
|
.setEndpoint(endpoint ?? '')
|
||||||
|
.setProject(projectId ?? '');
|
||||||
|
|
||||||
|
export const account = new Account(client);
|
||||||
|
export const databases = new Databases(client);
|
||||||
|
|
||||||
|
// Collection / database identifiers
|
||||||
|
export const DB_ID = 'budget_db';
|
||||||
|
|
||||||
|
export const COLLECTIONS = {
|
||||||
|
BALANCE_SHEETS: 'balance_sheets',
|
||||||
|
INCOMES: 'incomes',
|
||||||
|
BUCKETS: 'buckets',
|
||||||
|
DEBTS: 'debts',
|
||||||
|
TRANSACTIONS: 'transactions',
|
||||||
|
} as const;
|
||||||
149
src/appwrite/db.ts
Normal file
149
src/appwrite/db.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { databases, DB_ID, COLLECTIONS } from './config';
|
||||||
|
import type {
|
||||||
|
BalanceSheet,
|
||||||
|
Income,
|
||||||
|
Bucket,
|
||||||
|
Debt,
|
||||||
|
Transaction,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Omit$<T> = Omit<T, '$id' | '$createdAt' | '$updatedAt'>;
|
||||||
|
|
||||||
|
async function list<T>(collection: string, queries: string[] = []): Promise<T[]> {
|
||||||
|
const res = await databases.listDocuments(DB_ID, collection, [
|
||||||
|
...queries,
|
||||||
|
Query.limit(200),
|
||||||
|
]);
|
||||||
|
return res.documents as unknown as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Balance Sheets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getBalanceSheets(userId: string): Promise<BalanceSheet[]> {
|
||||||
|
return list<BalanceSheet>(COLLECTIONS.BALANCE_SHEETS, [
|
||||||
|
Query.equal('user_id', userId),
|
||||||
|
Query.orderDesc('year'),
|
||||||
|
Query.orderDesc('month'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalanceSheet(
|
||||||
|
userId: string,
|
||||||
|
month: number,
|
||||||
|
year: number,
|
||||||
|
): Promise<BalanceSheet | null> {
|
||||||
|
const docs = await list<BalanceSheet>(COLLECTIONS.BALANCE_SHEETS, [
|
||||||
|
Query.equal('user_id', userId),
|
||||||
|
Query.equal('month', month),
|
||||||
|
Query.equal('year', year),
|
||||||
|
]);
|
||||||
|
return docs[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertBalanceSheet(
|
||||||
|
data: Omit$<BalanceSheet>,
|
||||||
|
existingId?: string,
|
||||||
|
): Promise<BalanceSheet> {
|
||||||
|
if (existingId) {
|
||||||
|
return databases.updateDocument(DB_ID, COLLECTIONS.BALANCE_SHEETS, existingId, data) as unknown as BalanceSheet;
|
||||||
|
}
|
||||||
|
return databases.createDocument(DB_ID, COLLECTIONS.BALANCE_SHEETS, ID.unique(), data) as unknown as BalanceSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Incomes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getIncomes(
|
||||||
|
userId: string,
|
||||||
|
balanceSheetId: string,
|
||||||
|
): Promise<Income[]> {
|
||||||
|
return list<Income>(COLLECTIONS.INCOMES, [
|
||||||
|
Query.equal('user_id', userId),
|
||||||
|
Query.equal('balance_sheet_id', balanceSheetId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIncome(data: Omit$<Income>): Promise<Income> {
|
||||||
|
return databases.createDocument(DB_ID, COLLECTIONS.INCOMES, ID.unique(), data) as unknown as Income;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIncome(id: string, data: Partial<Omit$<Income>>): Promise<Income> {
|
||||||
|
return databases.updateDocument(DB_ID, COLLECTIONS.INCOMES, id, data) as unknown as Income;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIncome(id: string): Promise<void> {
|
||||||
|
await databases.deleteDocument(DB_ID, COLLECTIONS.INCOMES, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Buckets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getBuckets(userId: string): Promise<Bucket[]> {
|
||||||
|
return list<Bucket>(COLLECTIONS.BUCKETS, [
|
||||||
|
Query.equal('user_id', userId),
|
||||||
|
Query.orderAsc('sort_order'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBucket(data: Omit$<Bucket>): Promise<Bucket> {
|
||||||
|
return databases.createDocument(DB_ID, COLLECTIONS.BUCKETS, ID.unique(), data) as unknown as Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBucket(id: string, data: Partial<Omit$<Bucket>>): Promise<Bucket> {
|
||||||
|
return databases.updateDocument(DB_ID, COLLECTIONS.BUCKETS, id, data) as unknown as Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBucket(id: string): Promise<void> {
|
||||||
|
await databases.deleteDocument(DB_ID, COLLECTIONS.BUCKETS, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Debts ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getDebts(userId: string): Promise<Debt[]> {
|
||||||
|
return list<Debt>(COLLECTIONS.DEBTS, [
|
||||||
|
Query.equal('user_id', userId),
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDebt(data: Omit$<Debt>): Promise<Debt> {
|
||||||
|
return databases.createDocument(DB_ID, COLLECTIONS.DEBTS, ID.unique(), data) as unknown as Debt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDebt(id: string, data: Partial<Omit$<Debt>>): Promise<Debt> {
|
||||||
|
return databases.updateDocument(DB_ID, COLLECTIONS.DEBTS, id, data) as unknown as Debt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDebt(id: string): Promise<void> {
|
||||||
|
await databases.deleteDocument(DB_ID, COLLECTIONS.DEBTS, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transactions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTransactions(
|
||||||
|
userId: string,
|
||||||
|
bucketId?: string,
|
||||||
|
): Promise<Transaction[]> {
|
||||||
|
const queries = [
|
||||||
|
Query.equal('user_id', userId),
|
||||||
|
Query.orderDesc('date'),
|
||||||
|
];
|
||||||
|
if (bucketId) queries.push(Query.equal('bucket_id', bucketId));
|
||||||
|
return list<Transaction>(COLLECTIONS.TRANSACTIONS, queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTransaction(
|
||||||
|
data: Omit$<Transaction>,
|
||||||
|
): Promise<Transaction> {
|
||||||
|
return databases.createDocument(
|
||||||
|
DB_ID,
|
||||||
|
COLLECTIONS.TRANSACTIONS,
|
||||||
|
ID.unique(),
|
||||||
|
data,
|
||||||
|
) as unknown as Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTransaction(id: string): Promise<void> {
|
||||||
|
await databases.deleteDocument(DB_ID, COLLECTIONS.TRANSACTIONS, id);
|
||||||
|
}
|
||||||
37
src/components/layout/BottomNav.tsx
Normal file
37
src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { LayoutDashboard, Wallet, CreditCard, MoreHorizontal } from 'lucide-react';
|
||||||
|
import { clsx } from '../../lib/utils';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ to: '/buckets', icon: Wallet, label: 'Buckets' },
|
||||||
|
{ to: '/debts', icon: CreditCard, label: 'Debts' },
|
||||||
|
{ to: '/more', icon: MoreHorizontal, label: 'More' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 inset-x-0 z-40 bg-surface-overlay/90 backdrop-blur border-t border-slate-800 safe-area-bottom">
|
||||||
|
<div className="flex">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 transition-colors',
|
||||||
|
isActive ? 'text-accent' : 'text-slate-500 hover:text-slate-300',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={22} />
|
||||||
|
<span className="text-[10px] font-medium">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Safe area spacer for iOS/Android notch */}
|
||||||
|
<div className="h-safe-area-inset-bottom" />
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/layout/Header.tsx
Normal file
34
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
back?: boolean;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ title, subtitle, back, action }: HeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 bg-surface-overlay/90 backdrop-blur border-b border-slate-800 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{back && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="p-1.5 -ml-1 rounded-lg text-slate-400 hover:text-white hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-base font-semibold text-white truncate">{title}</h1>
|
||||||
|
{subtitle && <p className="text-xs text-slate-400 truncate">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div className="flex items-center gap-2 shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/layout/Layout.tsx
Normal file
15
src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { BottomNav } from './BottomNav';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-overlay text-white flex flex-col">
|
||||||
|
<main className="flex-1 pb-20 overflow-y-auto">{children}</main>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/ui/Badge.tsx
Normal file
70
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { clsx } from '../../lib/utils';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: ReactNode;
|
||||||
|
color?: string; // hex color
|
||||||
|
variant?: 'filled' | 'outline';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ children, color, variant = 'filled', size = 'sm' }: BadgeProps) {
|
||||||
|
const sizeClass = size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-3 py-1';
|
||||||
|
|
||||||
|
if (color) {
|
||||||
|
const style =
|
||||||
|
variant === 'filled'
|
||||||
|
? { backgroundColor: color + '33', color, border: `1px solid ${color}55` }
|
||||||
|
: { color, border: `1px solid ${color}` };
|
||||||
|
return (
|
||||||
|
<span className={clsx('inline-flex items-center rounded-full font-medium', sizeClass)} style={style}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center rounded-full font-medium bg-surface-raised text-slate-300',
|
||||||
|
sizeClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value: number; // 0-100
|
||||||
|
color?: string;
|
||||||
|
label?: string;
|
||||||
|
showPercent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ value, color = '#6366f1', label, showPercent }: ProgressBarProps) {
|
||||||
|
const clamped = Math.min(100, Math.max(0, value));
|
||||||
|
const overflowing = value > 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{(label || showPercent) && (
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
{label && <span className="text-xs text-slate-400">{label}</span>}
|
||||||
|
{showPercent && (
|
||||||
|
<span className="text-xs font-mono text-slate-300">{Math.round(value)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${clamped}%`,
|
||||||
|
backgroundColor: overflowing ? '#ef4444' : color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/ui/Button.tsx
Normal file
64
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { clsx } from '../../lib/utils';
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
fullWidth?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center justify-center font-medium rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-surface-overlay disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-accent hover:bg-accent-hover text-white focus:ring-accent',
|
||||||
|
secondary:
|
||||||
|
'bg-surface-raised hover:bg-slate-600 text-white focus:ring-slate-500',
|
||||||
|
ghost: 'bg-transparent hover:bg-surface-raised text-slate-300 focus:ring-slate-500',
|
||||||
|
danger: 'bg-danger hover:bg-red-600 text-white focus:ring-danger',
|
||||||
|
success: 'bg-success hover:bg-green-600 text-white focus:ring-success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'text-xs px-3 py-1.5 gap-1.5',
|
||||||
|
md: 'text-sm px-4 py-2.5 gap-2',
|
||||||
|
lg: 'text-base px-5 py-3 gap-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={clsx(base, variants[variant], sizes[size], fullWidth && 'w-full', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/ui/Card.tsx
Normal file
69
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { clsx } from '../../lib/utils';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
as?: 'div' | 'section' | 'article';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className, onClick, as: Tag = 'div' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'bg-surface rounded-2xl p-4',
|
||||||
|
onClick && 'cursor-pointer hover:bg-surface-raised transition-colors',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
color?: 'default' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ label, value, sub, color = 'default', icon }: StatCardProps) {
|
||||||
|
const colorMap = {
|
||||||
|
default: 'text-white',
|
||||||
|
success: 'text-success',
|
||||||
|
warning: 'text-warning',
|
||||||
|
danger: 'text-danger',
|
||||||
|
info: 'text-info',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">{label}</p>
|
||||||
|
<p className={clsx('text-xl font-semibold font-mono', colorMap[color])}>{value}</p>
|
||||||
|
{sub && <p className="text-xs text-slate-500 mt-0.5">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
{icon && <div className="text-slate-500">{icon}</div>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({ title, action }: SectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">{title}</h2>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/ui/Input.tsx
Normal file
103
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { clsx } from '../../lib/utils';
|
||||||
|
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
prefix?: ReactNode;
|
||||||
|
suffix?: ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
fullWidth = true,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
|
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
{prefix && (
|
||||||
|
<span className="absolute left-3 text-slate-400 text-sm">{prefix}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
className={clsx(
|
||||||
|
'w-full rounded-xl bg-surface-raised border border-slate-600',
|
||||||
|
'text-white placeholder-slate-500 text-sm',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent',
|
||||||
|
'transition-colors py-2.5',
|
||||||
|
prefix ? 'pl-8 pr-3' : 'px-3',
|
||||||
|
suffix ? 'pr-10' : '',
|
||||||
|
error && 'border-danger focus:ring-danger',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{suffix && (
|
||||||
|
<span className="absolute right-3 text-slate-400 text-sm">{suffix}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
{hint && !error && <p className="text-xs text-slate-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
fullWidth = true,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectProps) {
|
||||||
|
const selectId = id ?? label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={selectId} className="text-sm font-medium text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
className={clsx(
|
||||||
|
'w-full rounded-xl bg-surface-raised border border-slate-600',
|
||||||
|
'text-white text-sm',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent',
|
||||||
|
'transition-colors py-2.5 px-3',
|
||||||
|
error && 'border-danger',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/ui/Modal.tsx
Normal file
114
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { clsx } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ open, onClose, title, children, size = 'md' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'relative w-full bg-surface-overlay rounded-t-3xl sm:rounded-2xl shadow-2xl',
|
||||||
|
'max-h-[90vh] overflow-y-auto',
|
||||||
|
sizes[size],
|
||||||
|
'sm:mx-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
|
<h2 className="text-base font-semibold text-white">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
danger = false,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
<div className="relative bg-surface-overlay rounded-2xl p-6 max-w-sm w-full shadow-2xl">
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">{title}</h2>
|
||||||
|
<p className="text-sm text-slate-400 mb-6">{message}</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 rounded-xl bg-surface-raised text-slate-300 text-sm font-medium hover:bg-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 py-2.5 rounded-xl text-white text-sm font-medium transition-colors',
|
||||||
|
danger
|
||||||
|
? 'bg-danger hover:bg-red-600'
|
||||||
|
: 'bg-accent hover:bg-accent-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/index.css
Normal file
76
src/index.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: white;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
/* Support for safe areas on modern Android/iOS */
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number inputs – remove spinners */
|
||||||
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type='number'] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date input dark styling */
|
||||||
|
input[type='date']::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1) opacity(0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select arrow */
|
||||||
|
select {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 36px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Bottom safe area for PWA on Android/iOS */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full viewport height that respects the browser UI on mobile */
|
||||||
|
.h-dvh {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/lib/calculations.ts
Normal file
205
src/lib/calculations.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import type {
|
||||||
|
Income,
|
||||||
|
BalanceSheet,
|
||||||
|
Bucket,
|
||||||
|
Debt,
|
||||||
|
MonthlySnapshot,
|
||||||
|
BucketAllocation,
|
||||||
|
LoanAnalysis,
|
||||||
|
DebtRepaymentPlan,
|
||||||
|
InvestmentProjection,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ─── Income ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function toMonthly(amount: number, frequency: 'monthly' | 'yearly'): number {
|
||||||
|
return frequency === 'yearly' ? amount / 12 : amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalMonthlyIncome(incomes: Income[]): number {
|
||||||
|
return incomes.reduce((sum, i) => sum + toMonthly(i.amount, i.frequency), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Buffer ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function bufferAmount(sheet: BalanceSheet, monthlyIncome: number): number {
|
||||||
|
if (sheet.buffer_type === 'percent') {
|
||||||
|
return monthlyIncome * (sheet.buffer_value / 100);
|
||||||
|
}
|
||||||
|
return sheet.buffer_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bucket allocations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function bucketMonthlyAllocation(bucket: Bucket, monthlyIncome: number): number {
|
||||||
|
if (bucket.goal_amount <= 0) return 0;
|
||||||
|
let amount = bucket.goal_amount;
|
||||||
|
if (bucket.goal_type === 'percent') {
|
||||||
|
amount = monthlyIncome * (bucket.goal_amount / 100);
|
||||||
|
}
|
||||||
|
if (bucket.goal_frequency === 'yearly') {
|
||||||
|
amount = amount / 12;
|
||||||
|
}
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allBucketAllocations(
|
||||||
|
buckets: Bucket[],
|
||||||
|
monthlyIncome: number,
|
||||||
|
): BucketAllocation[] {
|
||||||
|
return buckets.map((b) => ({
|
||||||
|
bucket: b,
|
||||||
|
monthlyAmount: bucketMonthlyAllocation(b, monthlyIncome),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Debt ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EMI formula: P * r * (1+r)^n / ((1+r)^n - 1)
|
||||||
|
* r = monthly interest rate, n = term in months
|
||||||
|
*/
|
||||||
|
export function calculateEMI(principal: number, annualRate: number, termMonths: number): number {
|
||||||
|
if (annualRate === 0) return principal / termMonths;
|
||||||
|
const r = annualRate / 100 / 12;
|
||||||
|
const emi = (principal * r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1);
|
||||||
|
return emi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalDebtPayments(debts: Debt[]): number {
|
||||||
|
return debts.reduce((sum, d) => sum + d.monthly_payment, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Monthly Snapshot ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildMonthlySnapshot(
|
||||||
|
sheet: BalanceSheet | null,
|
||||||
|
incomes: Income[],
|
||||||
|
buckets: Bucket[],
|
||||||
|
debts: Debt[],
|
||||||
|
): MonthlySnapshot {
|
||||||
|
const monthlyIncome = totalMonthlyIncome(incomes);
|
||||||
|
const buffer = sheet ? bufferAmount(sheet, monthlyIncome) : 0;
|
||||||
|
const allocations = allBucketAllocations(buckets, monthlyIncome);
|
||||||
|
const totalBucket = allocations.reduce((s, a) => s + a.monthlyAmount, 0);
|
||||||
|
const debtPayments = totalDebtPayments(debts);
|
||||||
|
const available = monthlyIncome - buffer - totalBucket - debtPayments;
|
||||||
|
|
||||||
|
return {
|
||||||
|
balanceSheet: sheet,
|
||||||
|
incomes,
|
||||||
|
totalMonthlyIncome: monthlyIncome,
|
||||||
|
bufferAmount: buffer,
|
||||||
|
bucketAllocations: allocations,
|
||||||
|
totalBucketAllocation: totalBucket,
|
||||||
|
debtPayments,
|
||||||
|
available,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loan Calculator ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a monthly payment P, monthly rate r and term n, the max principal is:
|
||||||
|
* principal = P * (1 - (1+r)^-n) / r
|
||||||
|
*/
|
||||||
|
export function maxLoanPrincipal(
|
||||||
|
monthlyPayment: number,
|
||||||
|
annualRate: number,
|
||||||
|
termMonths: number,
|
||||||
|
): number {
|
||||||
|
if (annualRate === 0) return monthlyPayment * termMonths;
|
||||||
|
const r = annualRate / 100 / 12;
|
||||||
|
return (monthlyPayment * (1 - Math.pow(1 + r, -termMonths))) / r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeLoan(
|
||||||
|
availableMonthly: number,
|
||||||
|
annualRate: number,
|
||||||
|
termMonths: number,
|
||||||
|
desiredPrincipal?: number,
|
||||||
|
): LoanAnalysis {
|
||||||
|
const r = annualRate / 100 / 12;
|
||||||
|
const maxPrincipal = maxLoanPrincipal(availableMonthly, annualRate, termMonths);
|
||||||
|
const principalForCalc = desiredPrincipal ?? maxPrincipal;
|
||||||
|
const monthly = calculateEMI(principalForCalc, annualRate, termMonths);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxAffordablePrincipal: maxPrincipal,
|
||||||
|
monthlyPaymentForMax: availableMonthly,
|
||||||
|
availableForLoan: availableMonthly,
|
||||||
|
rateMonthly: r * 100,
|
||||||
|
termMonths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Debt repayment plan (avalanche method) ───────────────────────────────────
|
||||||
|
|
||||||
|
export function buildRepaymentPlan(
|
||||||
|
debts: Debt[],
|
||||||
|
extraMonthly: number,
|
||||||
|
): DebtRepaymentPlan[] {
|
||||||
|
if (debts.length === 0) return [];
|
||||||
|
|
||||||
|
// Sort by highest interest rate first (avalanche)
|
||||||
|
const sorted = [...debts].sort((a, b) => b.interest_rate - a.interest_rate);
|
||||||
|
let remaining = extraMonthly;
|
||||||
|
|
||||||
|
return sorted.map((debt, idx) => {
|
||||||
|
const extra = idx === 0 ? remaining : 0; // put all extra on highest-rate debt
|
||||||
|
const r = debt.interest_rate / 100 / 12;
|
||||||
|
const payment = debt.monthly_payment + extra;
|
||||||
|
let balance = debt.remaining_balance;
|
||||||
|
let months = 0;
|
||||||
|
let totalInterest = 0;
|
||||||
|
|
||||||
|
while (balance > 0 && months < 600) {
|
||||||
|
const interest = balance * r;
|
||||||
|
totalInterest += interest;
|
||||||
|
const principal = Math.min(payment - interest, balance);
|
||||||
|
balance -= principal;
|
||||||
|
months++;
|
||||||
|
if (payment <= interest) {
|
||||||
|
months = 9999; // never paid off
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
debt,
|
||||||
|
suggestedExtra: extra,
|
||||||
|
monthsToPayoff: months,
|
||||||
|
totalInterest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Investment projection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function projectInvestment(
|
||||||
|
principal: number,
|
||||||
|
annualReturnPercent: number,
|
||||||
|
years: number,
|
||||||
|
): number {
|
||||||
|
const r = annualReturnPercent / 100;
|
||||||
|
return principal * Math.pow(1 + r, years);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInvestmentProjection(bucket: Bucket): InvestmentProjection {
|
||||||
|
const annual =
|
||||||
|
bucket.return_frequency === 'monthly'
|
||||||
|
? bucket.return_percent * 12
|
||||||
|
: bucket.return_percent;
|
||||||
|
|
||||||
|
const monthly = bucket.return_frequency === 'monthly'
|
||||||
|
? bucket.current_balance * (bucket.return_percent / 100)
|
||||||
|
: bucket.current_balance * (bucket.return_percent / 100 / 12);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
monthlyReturn: monthly,
|
||||||
|
projectedValue1Y: projectInvestment(bucket.current_balance, annual, 1),
|
||||||
|
projectedValue5Y: projectInvestment(bucket.current_balance, annual, 5),
|
||||||
|
projectedValue10Y: projectInvestment(bucket.current_balance, annual, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
62
src/lib/utils.ts
Normal file
62
src/lib/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number, decimals = 1): string {
|
||||||
|
return `${value.toFixed(decimals)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
try {
|
||||||
|
return format(parseISO(dateString), 'MMM d, yyyy');
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthName(month: number): string {
|
||||||
|
return new Date(2000, month - 1, 1).toLocaleString('default', { month: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentMonthYear(): { month: number; year: number } {
|
||||||
|
const now = new Date();
|
||||||
|
return { month: now.getMonth() + 1, year: now.getFullYear() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clsx(...classes: (string | boolean | undefined | null)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUCKET_COLORS = [
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#10b981', // emerald
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#ef4444', // red
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#f97316', // orange
|
||||||
|
'#84cc16', // lime
|
||||||
|
];
|
||||||
|
|
||||||
|
export function randomBucketColor(): string {
|
||||||
|
return BUCKET_COLORS[Math.floor(Math.random() * BUCKET_COLORS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthsToReadable(months: number): string {
|
||||||
|
if (months >= 9999) return 'Never (payment too low)';
|
||||||
|
if (months >= 12) {
|
||||||
|
const y = Math.floor(months / 12);
|
||||||
|
const m = months % 12;
|
||||||
|
return m > 0 ? `${y}y ${m}m` : `${y} year${y > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
return `${months} month${months !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
108
src/pages/Auth.tsx
Normal file
108
src/pages/Auth.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { login, register } from '../appwrite/auth';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { getCurrentUser } from '../appwrite/auth';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
|
||||||
|
export function Auth() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setUser = useStore((s) => s.setUser);
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||||
|
const [form, setForm] = useState({ name: '', email: '', password: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (mode === 'register') {
|
||||||
|
await register(form.email, form.password, form.name);
|
||||||
|
} else {
|
||||||
|
await login(form.email, form.password);
|
||||||
|
}
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
setUser(user);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Authentication failed';
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-overlay flex flex-col items-center justify-center px-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-accent rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-3xl font-bold text-white">B</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">BudgetWise</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">Personal finance, synced everywhere</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="w-full max-w-sm bg-surface rounded-2xl p-6">
|
||||||
|
<div className="flex gap-1 mb-6 bg-surface-raised rounded-xl p-1">
|
||||||
|
{(['login', 'register'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors capitalize ${
|
||||||
|
mode === m ? 'bg-accent text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'login' ? 'Sign In' : 'Sign Up'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{mode === 'register' && (
|
||||||
|
<Input
|
||||||
|
label="Full name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-danger bg-red-950/50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<Button type="submit" fullWidth loading={loading} size="lg">
|
||||||
|
{mode === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-slate-500 text-center max-w-xs">
|
||||||
|
Your data is stored in your self-hosted Appwrite instance. Configure the endpoint in Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/pages/BalanceSheet.tsx
Normal file
316
src/pages/BalanceSheet.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import {
|
||||||
|
upsertBalanceSheet,
|
||||||
|
getIncomes,
|
||||||
|
createIncome,
|
||||||
|
updateIncome,
|
||||||
|
deleteIncome,
|
||||||
|
getBalanceSheet,
|
||||||
|
} from '../appwrite/db';
|
||||||
|
import { totalMonthlyIncome, toMonthly } from '../lib/calculations';
|
||||||
|
import { formatCurrency, monthName } from '../lib/utils';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, SectionHeader } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input, Select } from '../components/ui/Input';
|
||||||
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import type { Income } from '../types';
|
||||||
|
|
||||||
|
function IncomeForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: Partial<Income>;
|
||||||
|
onSave: (data: { name: string; amount: number; frequency: 'monthly' | 'yearly' }) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
|
const [amount, setAmount] = useState(String(initial?.amount ?? ''));
|
||||||
|
const [freq, setFreq] = useState<'monthly' | 'yearly'>(initial?.frequency ?? 'monthly');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Source name"
|
||||||
|
placeholder="Salary, Freelance, etc."
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Amount"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
prefix="$"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Frequency"
|
||||||
|
value={freq}
|
||||||
|
onChange={(e) => setFreq(e.target.value as 'monthly' | 'yearly')}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</Select>
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<Button variant="secondary" fullWidth onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
onClick={() => onSave({ name, amount: parseFloat(amount) || 0, frequency: freq })}
|
||||||
|
disabled={!name || !amount}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BalanceSheet() {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
selectedMonth,
|
||||||
|
selectedYear,
|
||||||
|
balanceSheet,
|
||||||
|
setBalanceSheet,
|
||||||
|
incomes,
|
||||||
|
setIncomes,
|
||||||
|
addIncome,
|
||||||
|
removeIncome,
|
||||||
|
updateIncomeItem,
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showIncomeModal, setShowIncomeModal] = useState(false);
|
||||||
|
const [editingIncome, setEditingIncome] = useState<Income | null>(null);
|
||||||
|
const [bufferType, setBufferType] = useState<'amount' | 'percent'>(
|
||||||
|
balanceSheet?.buffer_type ?? 'amount',
|
||||||
|
);
|
||||||
|
const [bufferValue, setBufferValue] = useState(String(balanceSheet?.buffer_value ?? ''));
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const bs = await getBalanceSheet(user.$id, selectedMonth, selectedYear);
|
||||||
|
setBalanceSheet(bs);
|
||||||
|
if (bs) {
|
||||||
|
setBufferType(bs.buffer_type);
|
||||||
|
setBufferValue(String(bs.buffer_value));
|
||||||
|
const incs = await getIncomes(user.$id, bs.$id);
|
||||||
|
setIncomes(incs);
|
||||||
|
} else {
|
||||||
|
setIncomes([]);
|
||||||
|
}
|
||||||
|
}, [user, selectedMonth, selectedYear, setBalanceSheet, setIncomes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
async function saveSheet() {
|
||||||
|
if (!user) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
month: selectedMonth,
|
||||||
|
year: selectedYear,
|
||||||
|
buffer_type: bufferType,
|
||||||
|
buffer_value: parseFloat(bufferValue) || 0,
|
||||||
|
user_id: user.$id,
|
||||||
|
};
|
||||||
|
const bs = await upsertBalanceSheet(data, balanceSheet?.$id);
|
||||||
|
setBalanceSheet(bs);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddIncome(data: {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
frequency: 'monthly' | 'yearly';
|
||||||
|
}) {
|
||||||
|
if (!user || !balanceSheet) return;
|
||||||
|
const income = await createIncome({
|
||||||
|
...data,
|
||||||
|
balance_sheet_id: balanceSheet.$id,
|
||||||
|
user_id: user.$id,
|
||||||
|
});
|
||||||
|
addIncome(income);
|
||||||
|
setShowIncomeModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateIncome(data: {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
frequency: 'monthly' | 'yearly';
|
||||||
|
}) {
|
||||||
|
if (!editingIncome) return;
|
||||||
|
const updated = await updateIncome(editingIncome.$id, data);
|
||||||
|
updateIncomeItem(editingIncome.$id, updated);
|
||||||
|
setEditingIncome(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteIncome(id: string) {
|
||||||
|
await deleteIncome(id);
|
||||||
|
removeIncome(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyTotal = totalMonthlyIncome(incomes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header
|
||||||
|
title="Balance Sheet"
|
||||||
|
subtitle={`${monthName(selectedMonth)} ${selectedYear}`}
|
||||||
|
back
|
||||||
|
/>
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{/* Buffer Settings */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Monthly Buffer" />
|
||||||
|
<Card>
|
||||||
|
<div className="flex gap-3 mb-3">
|
||||||
|
{(['amount', 'percent'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setBufferType(t)}
|
||||||
|
className={`flex-1 py-2 text-sm rounded-xl transition-colors font-medium ${
|
||||||
|
bufferType === t
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-surface-raised text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'amount' ? 'Fixed Amount' : 'Percentage'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={bufferType === 'amount' ? '500.00' : '10'}
|
||||||
|
value={bufferValue}
|
||||||
|
onChange={(e) => setBufferValue(e.target.value)}
|
||||||
|
prefix={bufferType === 'amount' ? '$' : undefined}
|
||||||
|
suffix={bufferType === 'percent' ? '%' : undefined}
|
||||||
|
inputMode="decimal"
|
||||||
|
hint={
|
||||||
|
bufferType === 'percent' && monthlyTotal > 0
|
||||||
|
? `= ${formatCurrency((monthlyTotal * parseFloat(bufferValue || '0')) / 100)} / month`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
className="mt-3"
|
||||||
|
onClick={saveSheet}
|
||||||
|
loading={loading}
|
||||||
|
variant={saved ? 'success' : 'primary'}
|
||||||
|
>
|
||||||
|
{saved ? '✓ Saved' : 'Save Buffer Settings'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Income Sources */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
title="Income Sources"
|
||||||
|
action={
|
||||||
|
balanceSheet && (
|
||||||
|
<Button size="sm" onClick={() => setShowIncomeModal(true)}>
|
||||||
|
<Plus size={14} /> Add
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!balanceSheet && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-400 text-center py-2">
|
||||||
|
Save buffer settings first to add income sources.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{incomes.length === 0 && balanceSheet && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-400 text-center py-3">No income sources yet.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{incomes.map((inc) => (
|
||||||
|
<Card key={inc.$id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{inc.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{formatCurrency(inc.amount)} / {inc.frequency}
|
||||||
|
{inc.frequency === 'yearly' && (
|
||||||
|
<span className="ml-1 text-slate-500">
|
||||||
|
({formatCurrency(inc.amount / 12)}/mo)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingIncome(inc)}
|
||||||
|
className="text-xs text-accent hover:underline px-2"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteIncome(inc.$id)}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-danger transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{incomes.length > 0 && (
|
||||||
|
<div className="mt-3 bg-surface rounded-2xl px-4 py-3 flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-400">Total Monthly Income</span>
|
||||||
|
<span className="text-base font-semibold font-mono text-success">
|
||||||
|
{formatCurrency(monthlyTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={showIncomeModal}
|
||||||
|
onClose={() => setShowIncomeModal(false)}
|
||||||
|
title="Add Income Source"
|
||||||
|
>
|
||||||
|
<IncomeForm
|
||||||
|
onSave={handleAddIncome}
|
||||||
|
onCancel={() => setShowIncomeModal(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!editingIncome}
|
||||||
|
onClose={() => setEditingIncome(null)}
|
||||||
|
title="Edit Income Source"
|
||||||
|
>
|
||||||
|
<IncomeForm
|
||||||
|
initial={editingIncome ?? undefined}
|
||||||
|
onSave={handleUpdateIncome}
|
||||||
|
onCancel={() => setEditingIncome(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
src/pages/BucketDetail.tsx
Normal file
314
src/pages/BucketDetail.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Plus, ArrowDownLeft, ArrowUpRight, Trash2, TrendingUp } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { getTransactions, createTransaction, deleteTransaction, updateBucket } from '../appwrite/db';
|
||||||
|
import { buildInvestmentProjection } from '../lib/calculations';
|
||||||
|
import { formatCurrency, formatDate, monthsToReadable } from '../lib/utils';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, SectionHeader, StatCard } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input, Select } from '../components/ui/Input';
|
||||||
|
import { Modal, ConfirmModal } from '../components/ui/Modal';
|
||||||
|
import { ProgressBar } from '../components/ui/Badge';
|
||||||
|
import type { Transaction } from '../types';
|
||||||
|
|
||||||
|
export function BucketDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { user, buckets, updateBucketItem, transactions, setTransactions, addTransaction, removeTransaction } =
|
||||||
|
useStore();
|
||||||
|
|
||||||
|
const bucket = buckets.find((b) => b.$id === id);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [deletingTxn, setDeletingTxn] = useState<Transaction | null>(null);
|
||||||
|
const [txnType, setTxnType] = useState<'deposit' | 'withdrawal'>('deposit');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [txnDate, setTxnDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
|
||||||
|
const bucketTxns = transactions.filter((t) => t.bucket_id === id);
|
||||||
|
|
||||||
|
const loadTxns = useCallback(async () => {
|
||||||
|
if (!user || !id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const txns = await getTransactions(user.$id, id);
|
||||||
|
setTransactions(txns);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user, id, setTransactions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTxns();
|
||||||
|
}, [loadTxns]);
|
||||||
|
|
||||||
|
async function handleAddTransaction() {
|
||||||
|
if (!user || !bucket || !amount) return;
|
||||||
|
const amt = parseFloat(amount);
|
||||||
|
if (isNaN(amt) || amt <= 0) return;
|
||||||
|
|
||||||
|
const newBalance =
|
||||||
|
txnType === 'deposit'
|
||||||
|
? bucket.current_balance + amt
|
||||||
|
: bucket.current_balance - amt;
|
||||||
|
|
||||||
|
const txn = await createTransaction({
|
||||||
|
bucket_id: bucket.$id,
|
||||||
|
type: txnType,
|
||||||
|
amount: amt,
|
||||||
|
date: txnDate,
|
||||||
|
notes,
|
||||||
|
balance_after: newBalance,
|
||||||
|
user_id: user.$id,
|
||||||
|
});
|
||||||
|
|
||||||
|
addTransaction(txn);
|
||||||
|
|
||||||
|
// Update bucket balance
|
||||||
|
const updated = await updateBucket(bucket.$id, { current_balance: newBalance });
|
||||||
|
updateBucketItem(bucket.$id, updated);
|
||||||
|
|
||||||
|
setAmount('');
|
||||||
|
setNotes('');
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteTransaction() {
|
||||||
|
if (!deletingTxn || !bucket) return;
|
||||||
|
// Reverse the transaction
|
||||||
|
const revert =
|
||||||
|
deletingTxn.type === 'deposit'
|
||||||
|
? bucket.current_balance - deletingTxn.amount
|
||||||
|
: bucket.current_balance + deletingTxn.amount;
|
||||||
|
|
||||||
|
await deleteTransaction(deletingTxn.$id);
|
||||||
|
removeTransaction(deletingTxn.$id);
|
||||||
|
|
||||||
|
const updated = await updateBucket(bucket.$id, { current_balance: revert });
|
||||||
|
updateBucketItem(bucket.$id, updated);
|
||||||
|
setDeletingTxn(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header title="Bucket" back />
|
||||||
|
<div className="px-4 py-8 text-center text-slate-400">Bucket not found.</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = bucket.goal_amount > 0 ? (bucket.current_balance / bucket.goal_amount) * 100 : 0;
|
||||||
|
const projection = bucket.type === 'investment' ? buildInvestmentProjection(bucket) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header title={bucket.name} subtitle={bucket.description || undefined} back />
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{/* Balance card */}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-5 text-center"
|
||||||
|
style={{ backgroundColor: bucket.color + '22', border: `1px solid ${bucket.color}44` }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-400 mb-1">Current Balance</p>
|
||||||
|
<p className="text-3xl font-bold font-mono" style={{ color: bucket.color }}>
|
||||||
|
{formatCurrency(bucket.current_balance)}
|
||||||
|
</p>
|
||||||
|
{bucket.goal_amount > 0 && (
|
||||||
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
|
Goal: {formatCurrency(bucket.goal_amount)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goal progress */}
|
||||||
|
{bucket.goal_amount > 0 && (
|
||||||
|
<Card>
|
||||||
|
<ProgressBar
|
||||||
|
value={progress}
|
||||||
|
color={bucket.color}
|
||||||
|
label="Goal Progress"
|
||||||
|
showPercent
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
{formatCurrency(Math.max(0, bucket.goal_amount - bucket.current_balance))} remaining
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Investment projection */}
|
||||||
|
{projection && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Investment Projection" />
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<StatCard label="1 Year" value={formatCurrency(projection.projectedValue1Y)} color="success" />
|
||||||
|
<StatCard label="5 Years" value={formatCurrency(projection.projectedValue5Y)} color="success" />
|
||||||
|
<StatCard label="10 Years" value={formatCurrency(projection.projectedValue10Y)} color="success" />
|
||||||
|
</div>
|
||||||
|
<Card className="mt-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<TrendingUp size={16} className="text-success" />
|
||||||
|
<span>
|
||||||
|
Monthly return:{' '}
|
||||||
|
<span className="font-mono text-success font-medium">
|
||||||
|
+{formatCurrency(projection.monthlyReturn)}
|
||||||
|
</span>{' '}
|
||||||
|
at {bucket.return_percent}% / {bucket.return_frequency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="success"
|
||||||
|
onClick={() => {
|
||||||
|
setTxnType('deposit');
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownLeft size={16} /> Deposit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setTxnType('withdrawal');
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUpRight size={16} /> Withdraw
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Transactions" />
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : bucketTxns.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-400 text-center py-3">No transactions yet.</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{bucketTxns.map((txn) => (
|
||||||
|
<Card key={txn.$id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||||
|
txn.type === 'deposit'
|
||||||
|
? 'bg-success/20 text-success'
|
||||||
|
: 'bg-danger/20 text-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{txn.type === 'deposit' ? (
|
||||||
|
<ArrowDownLeft size={14} />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{txn.notes || (txn.type === 'deposit' ? 'Deposit' : 'Withdrawal')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatDate(txn.date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-mono font-medium ${
|
||||||
|
txn.type === 'deposit' ? 'text-success' : 'text-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{txn.type === 'deposit' ? '+' : '-'}
|
||||||
|
{formatCurrency(txn.amount)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingTxn(txn)}
|
||||||
|
className="p-1 text-slate-600 hover:text-danger transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add transaction modal */}
|
||||||
|
<Modal
|
||||||
|
open={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
title={txnType === 'deposit' ? 'Deposit to Bucket' : 'Withdraw from Bucket'}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{(['deposit', 'withdrawal'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTxnType(t)}
|
||||||
|
className={`flex-1 py-2 text-sm rounded-xl font-medium transition-colors ${
|
||||||
|
txnType === t
|
||||||
|
? t === 'deposit'
|
||||||
|
? 'bg-success text-white'
|
||||||
|
: 'bg-danger text-white'
|
||||||
|
: 'bg-surface-raised text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'deposit' ? 'Deposit' : 'Withdraw'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
label="Amount"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
prefix="$"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Date"
|
||||||
|
type="date"
|
||||||
|
value={txnDate}
|
||||||
|
onChange={(e) => setTxnDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="What's this for?"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button fullWidth onClick={handleAddTransaction} disabled={!amount}>
|
||||||
|
{txnType === 'deposit' ? 'Deposit' : 'Withdraw'} {amount && formatCurrency(parseFloat(amount) || 0)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!deletingTxn}
|
||||||
|
onClose={() => setDeletingTxn(null)}
|
||||||
|
onConfirm={handleDeleteTransaction}
|
||||||
|
title="Delete transaction?"
|
||||||
|
message="This will also reverse the balance change."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
389
src/pages/Buckets.tsx
Normal file
389
src/pages/Buckets.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Plus, Wallet, TrendingUp, PiggyBank } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { getBuckets, createBucket, updateBucket, deleteBucket } from '../appwrite/db';
|
||||||
|
import { bucketMonthlyAllocation } from '../lib/calculations';
|
||||||
|
import { formatCurrency, randomBucketColor, BUCKET_COLORS } from '../lib/utils';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, SectionHeader } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input, Select } from '../components/ui/Input';
|
||||||
|
import { Modal, ConfirmModal } from '../components/ui/Modal';
|
||||||
|
import { Badge, ProgressBar } from '../components/ui/Badge';
|
||||||
|
import type { Bucket, BucketType } from '../types';
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
regular: Wallet,
|
||||||
|
savings: PiggyBank,
|
||||||
|
investment: TrendingUp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<BucketType, string> = {
|
||||||
|
regular: 'Regular',
|
||||||
|
savings: 'Savings',
|
||||||
|
investment: 'Investment',
|
||||||
|
};
|
||||||
|
|
||||||
|
function BucketForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: Partial<Bucket>;
|
||||||
|
onSave: (data: Omit<Bucket, '$id' | '$createdAt' | '$updatedAt'>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(initial?.description ?? '');
|
||||||
|
const [type, setType] = useState<BucketType>(initial?.type ?? 'regular');
|
||||||
|
const [color, setColor] = useState(initial?.color ?? randomBucketColor());
|
||||||
|
const [goalAmount, setGoalAmount] = useState(String(initial?.goal_amount ?? ''));
|
||||||
|
const [goalType, setGoalType] = useState<'amount' | 'percent'>(initial?.goal_type ?? 'amount');
|
||||||
|
const [goalFreq, setGoalFreq] = useState<'monthly' | 'yearly'>(initial?.goal_frequency ?? 'monthly');
|
||||||
|
const [returnPct, setReturnPct] = useState(String(initial?.return_percent ?? ''));
|
||||||
|
const [returnFreq, setReturnFreq] = useState<'monthly' | 'yearly'>(initial?.return_frequency ?? 'yearly');
|
||||||
|
const { user } = useStore();
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
onSave({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
color,
|
||||||
|
goal_amount: parseFloat(goalAmount) || 0,
|
||||||
|
goal_type: goalType,
|
||||||
|
goal_frequency: goalFreq,
|
||||||
|
return_percent: parseFloat(returnPct) || 0,
|
||||||
|
return_frequency: returnFreq,
|
||||||
|
current_balance: initial?.current_balance ?? 0,
|
||||||
|
sort_order: initial?.sort_order ?? 0,
|
||||||
|
user_id: user?.$id ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Bucket name"
|
||||||
|
placeholder="Emergency Fund, Vacation..."
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Description (optional)"
|
||||||
|
placeholder="What's this bucket for?"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select label="Type" value={type} onChange={(e) => setType(e.target.value as BucketType)}>
|
||||||
|
<option value="regular">Regular</option>
|
||||||
|
<option value="savings">Savings Goal</option>
|
||||||
|
<option value="investment">Investment</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Color picker */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-300 mb-2 block">Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BUCKET_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className="w-7 h-7 rounded-full transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: c,
|
||||||
|
outline: color === c ? `2px solid ${c}` : 'none',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goal settings */}
|
||||||
|
<div className="border-t border-slate-700 pt-4">
|
||||||
|
<p className="text-sm font-medium text-slate-300 mb-3">Goal / Allocation</p>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
{(['amount', 'percent'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setGoalType(t)}
|
||||||
|
className={`flex-1 py-1.5 text-xs rounded-lg transition-colors font-medium ${
|
||||||
|
goalType === t ? 'bg-accent text-white' : 'bg-surface-raised text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'amount' ? 'Fixed $' : '% of Income'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label={goalType === 'amount' ? 'Goal amount' : 'Percent of income'}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={goalAmount}
|
||||||
|
onChange={(e) => setGoalAmount(e.target.value)}
|
||||||
|
prefix={goalType === 'amount' ? '$' : undefined}
|
||||||
|
suffix={goalType === 'percent' ? '%' : undefined}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Frequency"
|
||||||
|
value={goalFreq}
|
||||||
|
onChange={(e) => setGoalFreq(e.target.value as 'monthly' | 'yearly')}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Investment settings */}
|
||||||
|
{type === 'investment' && (
|
||||||
|
<div className="border-t border-slate-700 pt-4">
|
||||||
|
<p className="text-sm font-medium text-slate-300 mb-3">Returns</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="Return %"
|
||||||
|
type="number"
|
||||||
|
placeholder="8"
|
||||||
|
value={returnPct}
|
||||||
|
onChange={(e) => setReturnPct(e.target.value)}
|
||||||
|
suffix="%"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
value={returnFreq}
|
||||||
|
onChange={(e) => setReturnFreq(e.target.value as 'monthly' | 'yearly')}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<Button variant="secondary" fullWidth onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button fullWidth onClick={submit} disabled={!name}>
|
||||||
|
Save Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Buckets() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, buckets, setBuckets, addBucket, updateBucketItem, removeBucket } = useStore();
|
||||||
|
const totalMonthlyIncome = useStore((s) =>
|
||||||
|
s.incomes.reduce(
|
||||||
|
(sum, i) => sum + (i.frequency === 'yearly' ? i.amount / 12 : i.amount),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
|
||||||
|
const [deletingBucket, setDeletingBucket] = useState<Bucket | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadBuckets = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const bkts = await getBuckets(user.$id);
|
||||||
|
setBuckets(bkts);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user, setBuckets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBuckets();
|
||||||
|
}, [loadBuckets]);
|
||||||
|
|
||||||
|
async function handleCreate(data: Omit<Bucket, '$id' | '$createdAt' | '$updatedAt'>) {
|
||||||
|
const b = await createBucket({ ...data, sort_order: buckets.length });
|
||||||
|
addBucket(b);
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(data: Omit<Bucket, '$id' | '$createdAt' | '$updatedAt'>) {
|
||||||
|
if (!editingBucket) return;
|
||||||
|
const b = await updateBucket(editingBucket.$id, data);
|
||||||
|
updateBucketItem(editingBucket.$id, b);
|
||||||
|
setEditingBucket(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingBucket) return;
|
||||||
|
await deleteBucket(deletingBucket.$id);
|
||||||
|
removeBucket(deletingBucket.$id);
|
||||||
|
setDeletingBucket(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAllocation = buckets.reduce(
|
||||||
|
(sum, b) => sum + bucketMonthlyAllocation(b, totalMonthlyIncome),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedBuckets = {
|
||||||
|
regular: buckets.filter((b) => b.type === 'regular'),
|
||||||
|
savings: buckets.filter((b) => b.type === 'savings'),
|
||||||
|
investment: buckets.filter((b) => b.type === 'investment'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function BucketList({ type }: { type: BucketType }) {
|
||||||
|
const list = groupedBuckets[type];
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
const Icon = TYPE_ICONS[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title={TYPE_LABELS[type]} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((b) => {
|
||||||
|
const allocation = bucketMonthlyAllocation(b, totalMonthlyIncome);
|
||||||
|
const progress =
|
||||||
|
b.goal_amount > 0 ? (b.current_balance / b.goal_amount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={b.$id} onClick={() => navigate(`/buckets/${b.$id}`)}>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-xl flex items-center justify-center shrink-0"
|
||||||
|
style={{ backgroundColor: b.color + '33', border: `1px solid ${b.color}55` }}
|
||||||
|
>
|
||||||
|
<Icon size={16} style={{ color: b.color }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{b.name}</p>
|
||||||
|
{b.description && (
|
||||||
|
<p className="text-xs text-slate-500 truncate max-w-[160px]">{b.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-mono font-semibold text-white">
|
||||||
|
{formatCurrency(b.current_balance)}
|
||||||
|
</p>
|
||||||
|
{allocation > 0 && (
|
||||||
|
<p className="text-xs text-slate-500">+{formatCurrency(allocation)}/mo</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{b.goal_amount > 0 && (
|
||||||
|
<ProgressBar value={progress} color={b.color} showPercent />
|
||||||
|
)}
|
||||||
|
{type === 'investment' && b.return_percent > 0 && (
|
||||||
|
<p className="text-xs text-success mt-1">
|
||||||
|
{b.return_percent}% / {b.return_frequency}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingBucket(b);
|
||||||
|
}}
|
||||||
|
className="text-xs text-slate-400 hover:text-white transition-colors px-2"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeletingBucket(b);
|
||||||
|
}}
|
||||||
|
className="text-xs text-slate-500 hover:text-danger transition-colors px-2"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header
|
||||||
|
title="Buckets"
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={() => setShowModal(true)}>
|
||||||
|
<Plus size={14} /> New
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{totalAllocation > 0 && (
|
||||||
|
<div className="bg-surface rounded-2xl px-4 py-3 flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-400">Total monthly allocation</span>
|
||||||
|
<span className="text-sm font-semibold font-mono text-white">
|
||||||
|
{formatCurrency(totalAllocation)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : buckets.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<Wallet size={32} className="text-slate-600 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-400 mb-2">No buckets yet.</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Create buckets for savings goals, investments, or expense categories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BucketList type="regular" />
|
||||||
|
<BucketList type="savings" />
|
||||||
|
<BucketList type="investment" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={showModal} onClose={() => setShowModal(false)} title="New Bucket" size="lg">
|
||||||
|
<BucketForm onSave={handleCreate} onCancel={() => setShowModal(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!editingBucket}
|
||||||
|
onClose={() => setEditingBucket(null)}
|
||||||
|
title="Edit Bucket"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<BucketForm
|
||||||
|
initial={editingBucket ?? undefined}
|
||||||
|
onSave={handleUpdate}
|
||||||
|
onCancel={() => setEditingBucket(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!deletingBucket}
|
||||||
|
onClose={() => setDeletingBucket(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete bucket?"
|
||||||
|
message={`"${deletingBucket?.name}" and all its transactions will be deleted. This cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
src/pages/Dashboard.tsx
Normal file
238
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ChevronLeft, ChevronRight, Plus, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { getBalanceSheet, getIncomes, getBuckets, getDebts } from '../appwrite/db';
|
||||||
|
import { buildMonthlySnapshot } from '../lib/calculations';
|
||||||
|
import { formatCurrency, monthName } from '../lib/utils';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, StatCard, SectionHeader } from '../components/ui/Card';
|
||||||
|
import { ProgressBar } from '../components/ui/Badge';
|
||||||
|
import type { MonthlySnapshot } from '../types';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
selectedMonth,
|
||||||
|
selectedYear,
|
||||||
|
setSelectedPeriod,
|
||||||
|
balanceSheet,
|
||||||
|
setBalanceSheet,
|
||||||
|
incomes,
|
||||||
|
setIncomes,
|
||||||
|
buckets,
|
||||||
|
setBuckets,
|
||||||
|
debts,
|
||||||
|
setDebts,
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
const [snapshot, setSnapshot] = useState<MonthlySnapshot | null>(null);
|
||||||
|
const [loadingData, setLoadingData] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoadingData(true);
|
||||||
|
try {
|
||||||
|
const [bs, bkts, dts] = await Promise.all([
|
||||||
|
getBalanceSheet(user.$id, selectedMonth, selectedYear),
|
||||||
|
getBuckets(user.$id),
|
||||||
|
getDebts(user.$id),
|
||||||
|
]);
|
||||||
|
setBalanceSheet(bs);
|
||||||
|
setBuckets(bkts);
|
||||||
|
setDebts(dts);
|
||||||
|
|
||||||
|
const incs = bs ? await getIncomes(user.$id, bs.$id) : [];
|
||||||
|
setIncomes(incs);
|
||||||
|
|
||||||
|
const snap = buildMonthlySnapshot(bs, incs, bkts, dts);
|
||||||
|
setSnapshot(snap);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
}, [user, selectedMonth, selectedYear, setBalanceSheet, setBuckets, setDebts, setIncomes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (selectedMonth === 1) setSelectedPeriod(12, selectedYear - 1);
|
||||||
|
else setSelectedPeriod(selectedMonth - 1, selectedYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (selectedMonth === 12) setSelectedPeriod(1, selectedYear + 1);
|
||||||
|
else setSelectedPeriod(selectedMonth + 1, selectedYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
const income = snapshot?.totalMonthlyIncome ?? 0;
|
||||||
|
const available = snapshot?.available ?? 0;
|
||||||
|
const spent = income - available;
|
||||||
|
const spentPct = income > 0 ? (spent / income) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header
|
||||||
|
title="BudgetWise"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/balance-sheet')}
|
||||||
|
className="p-2 rounded-xl bg-surface text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{/* Month Selector */}
|
||||||
|
<div className="flex items-center justify-between bg-surface rounded-2xl px-4 py-3">
|
||||||
|
<button onClick={prevMonth} className="p-1.5 text-slate-400 hover:text-white">
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold text-white">
|
||||||
|
{monthName(selectedMonth)} {selectedYear}
|
||||||
|
</span>
|
||||||
|
<button onClick={nextMonth} className="p-1.5 text-slate-400 hover:text-white">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !balanceSheet ? (
|
||||||
|
<Card>
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-slate-400 mb-3">No balance sheet for this month.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/balance-sheet')}
|
||||||
|
className="text-accent text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Set up income & budget →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Income Overview */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Overview" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<StatCard
|
||||||
|
label="Monthly Income"
|
||||||
|
value={formatCurrency(income)}
|
||||||
|
color="success"
|
||||||
|
icon={<TrendingUp size={20} />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Available"
|
||||||
|
value={formatCurrency(available)}
|
||||||
|
color={available >= 0 ? 'default' : 'danger'}
|
||||||
|
icon={<TrendingDown size={20} />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Buffer"
|
||||||
|
value={formatCurrency(snapshot?.bufferAmount ?? 0)}
|
||||||
|
sub={
|
||||||
|
balanceSheet.buffer_type === 'percent'
|
||||||
|
? `${balanceSheet.buffer_value}% of income`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Debt Payments"
|
||||||
|
value={formatCurrency(snapshot?.debtPayments ?? 0)}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spending bar */}
|
||||||
|
<Card>
|
||||||
|
<ProgressBar
|
||||||
|
value={spentPct}
|
||||||
|
label={`Allocated: ${formatCurrency(spent)}`}
|
||||||
|
showPercent
|
||||||
|
color={spentPct > 90 ? '#ef4444' : spentPct > 70 ? '#f59e0b' : '#6366f1'}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bucket Allocations */}
|
||||||
|
{snapshot && snapshot.bucketAllocations.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
title="Bucket Goals"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/buckets')}
|
||||||
|
className="text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{snapshot.bucketAllocations
|
||||||
|
.filter((a) => a.monthlyAmount > 0)
|
||||||
|
.map((a) => (
|
||||||
|
<Card
|
||||||
|
key={a.bucket.$id}
|
||||||
|
onClick={() => navigate(`/buckets/${a.bucket.$id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: a.bucket.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-white">{a.bucket.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-sm font-mono text-white">
|
||||||
|
{formatCurrency(a.monthlyAmount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{a.bucket.goal_amount > 0 && (
|
||||||
|
<ProgressBar
|
||||||
|
value={
|
||||||
|
a.bucket.goal_amount > 0
|
||||||
|
? (a.bucket.current_balance / a.bucket.goal_amount) * 100
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
color={a.bucket.color}
|
||||||
|
showPercent
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Quick Actions" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card onClick={() => navigate('/balance-sheet')}>
|
||||||
|
<p className="text-sm font-medium text-white">Edit Income</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Manage monthly income</p>
|
||||||
|
</Card>
|
||||||
|
<Card onClick={() => navigate('/loan-calculator')}>
|
||||||
|
<p className="text-sm font-medium text-white">Loan Calc</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Max affordable loan</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
src/pages/Debts.tsx
Normal file
370
src/pages/Debts.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Plus, Trash2, CreditCard, AlertCircle } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { getDebts, createDebt, updateDebt, deleteDebt } from '../appwrite/db';
|
||||||
|
import { calculateEMI, buildRepaymentPlan, buildMonthlySnapshot } from '../lib/calculations';
|
||||||
|
import { formatCurrency, monthsToReadable } from '../lib/utils';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, SectionHeader, StatCard } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input, Select } from '../components/ui/Input';
|
||||||
|
import { Modal, ConfirmModal } from '../components/ui/Modal';
|
||||||
|
import { ProgressBar } from '../components/ui/Badge';
|
||||||
|
import type { Debt } from '../types';
|
||||||
|
|
||||||
|
function DebtForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: Partial<Debt>;
|
||||||
|
onSave: (data: Omit<Debt, '$id' | '$createdAt' | '$updatedAt'>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useStore();
|
||||||
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
|
const [principal, setPrincipal] = useState(String(initial?.principal ?? ''));
|
||||||
|
const [remaining, setRemaining] = useState(String(initial?.remaining_balance ?? ''));
|
||||||
|
const [rate, setRate] = useState(String(initial?.interest_rate ?? ''));
|
||||||
|
const [rateFreq, setRateFreq] = useState<'monthly' | 'yearly'>(
|
||||||
|
initial?.interest_frequency ?? 'yearly',
|
||||||
|
);
|
||||||
|
const [termMonths, setTermMonths] = useState(String(initial?.term_months ?? ''));
|
||||||
|
const [manualPayment, setManualPayment] = useState(
|
||||||
|
String(initial?.is_auto_calculated ? '' : (initial?.monthly_payment ?? '')),
|
||||||
|
);
|
||||||
|
const [isAuto, setIsAuto] = useState(initial?.is_auto_calculated ?? true);
|
||||||
|
const [startDate, setStartDate] = useState(
|
||||||
|
initial?.start_date ?? new Date().toISOString().split('T')[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute auto EMI
|
||||||
|
const principalNum = parseFloat(principal) || 0;
|
||||||
|
const rateNum = parseFloat(rate) || 0;
|
||||||
|
const annualRate = rateFreq === 'monthly' ? rateNum * 12 : rateNum;
|
||||||
|
const termNum = parseInt(termMonths) || 0;
|
||||||
|
const autoEMI =
|
||||||
|
principalNum > 0 && annualRate > 0 && termNum > 0
|
||||||
|
? calculateEMI(principalNum, annualRate, termNum)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const payment = isAuto ? autoEMI : parseFloat(manualPayment) || 0;
|
||||||
|
onSave({
|
||||||
|
name,
|
||||||
|
principal: principalNum,
|
||||||
|
remaining_balance: parseFloat(remaining) || principalNum,
|
||||||
|
interest_rate: annualRate,
|
||||||
|
interest_frequency: 'yearly',
|
||||||
|
term_months: termNum,
|
||||||
|
monthly_payment: payment,
|
||||||
|
is_auto_calculated: isAuto,
|
||||||
|
start_date: startDate,
|
||||||
|
user_id: user?.$id ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input label="Debt name" placeholder="Home Loan, Car Loan..." value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="Original principal"
|
||||||
|
type="number"
|
||||||
|
placeholder="100000"
|
||||||
|
prefix="$"
|
||||||
|
value={principal}
|
||||||
|
onChange={(e) => setPrincipal(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Remaining balance"
|
||||||
|
type="number"
|
||||||
|
placeholder="Same as principal"
|
||||||
|
prefix="$"
|
||||||
|
value={remaining}
|
||||||
|
onChange={(e) => setRemaining(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="Interest rate"
|
||||||
|
type="number"
|
||||||
|
placeholder="8.5"
|
||||||
|
suffix="%"
|
||||||
|
value={rate}
|
||||||
|
onChange={(e) => setRate(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Rate period"
|
||||||
|
value={rateFreq}
|
||||||
|
onChange={(e) => setRateFreq(e.target.value as 'monthly' | 'yearly')}
|
||||||
|
>
|
||||||
|
<option value="yearly">Yearly (p.a.)</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Term (months)"
|
||||||
|
type="number"
|
||||||
|
placeholder="120"
|
||||||
|
value={termMonths}
|
||||||
|
onChange={(e) => setTermMonths(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
hint={termNum > 0 ? `= ${Math.floor(termNum / 12)}y ${termNum % 12}m` : undefined}
|
||||||
|
/>
|
||||||
|
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
|
||||||
|
{/* Payment mode */}
|
||||||
|
<div className="border-t border-slate-700 pt-3">
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
{([true, false] as const).map((auto) => (
|
||||||
|
<button
|
||||||
|
key={String(auto)}
|
||||||
|
onClick={() => setIsAuto(auto)}
|
||||||
|
className={`flex-1 py-2 text-sm rounded-xl transition-colors font-medium ${
|
||||||
|
isAuto === auto ? 'bg-accent text-white' : 'bg-surface-raised text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{auto ? 'Auto EMI' : 'Manual Payment'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isAuto ? (
|
||||||
|
<div className="bg-surface rounded-xl px-4 py-3">
|
||||||
|
<p className="text-xs text-slate-400">Computed monthly payment</p>
|
||||||
|
<p className="text-lg font-mono font-semibold text-white mt-0.5">
|
||||||
|
{autoEMI > 0 ? formatCurrency(autoEMI) : '—'}
|
||||||
|
</p>
|
||||||
|
{autoEMI > 0 && (
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Using EMI formula at {annualRate}% p.a. for {termNum} months
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
label="Monthly payment"
|
||||||
|
type="number"
|
||||||
|
prefix="$"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={manualPayment}
|
||||||
|
onChange={(e) => setManualPayment(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<Button variant="secondary" fullWidth onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button fullWidth onClick={submit} disabled={!name || !principal}>Save Debt</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Debts() {
|
||||||
|
const { user, debts, setDebts, addDebt, updateDebtItem, removeDebt, incomes, balanceSheet, buckets } = useStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingDebt, setEditingDebt] = useState<Debt | null>(null);
|
||||||
|
const [deletingDebt, setDeletingDebt] = useState<Debt | null>(null);
|
||||||
|
|
||||||
|
const loadDebts = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dts = await getDebts(user.$id);
|
||||||
|
setDebts(dts);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user, setDebts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDebts();
|
||||||
|
}, [loadDebts]);
|
||||||
|
|
||||||
|
async function handleCreate(data: Omit<Debt, '$id' | '$createdAt' | '$updatedAt'>) {
|
||||||
|
const d = await createDebt(data);
|
||||||
|
addDebt(d);
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(data: Omit<Debt, '$id' | '$createdAt' | '$updatedAt'>) {
|
||||||
|
if (!editingDebt) return;
|
||||||
|
const d = await updateDebt(editingDebt.$id, data);
|
||||||
|
updateDebtItem(editingDebt.$id, d);
|
||||||
|
setEditingDebt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingDebt) return;
|
||||||
|
await deleteDebt(deletingDebt.$id);
|
||||||
|
removeDebt(deletingDebt.$id);
|
||||||
|
setDeletingDebt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMonthly = debts.reduce((s, d) => s + d.monthly_payment, 0);
|
||||||
|
const totalRemaining = debts.reduce((s, d) => s + d.remaining_balance, 0);
|
||||||
|
|
||||||
|
// Repayment plan
|
||||||
|
const snapshot = buildMonthlySnapshot(balanceSheet, incomes, buckets, debts);
|
||||||
|
const extraAvailable = Math.max(0, snapshot.available);
|
||||||
|
const repaymentPlan = buildRepaymentPlan(debts, extraAvailable);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header
|
||||||
|
title="Debts"
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={() => setShowModal(true)}>
|
||||||
|
<Plus size={14} /> Add
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{/* Summary */}
|
||||||
|
{debts.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<StatCard label="Total Remaining" value={formatCurrency(totalRemaining)} color="danger" />
|
||||||
|
<StatCard label="Monthly Payments" value={formatCurrency(totalMonthly)} color="warning" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debt list */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Active Debts" />
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : debts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<CreditCard size={32} className="text-slate-600 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-400">No debts added.</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{debts.map((debt) => {
|
||||||
|
const paid = debt.principal - debt.remaining_balance;
|
||||||
|
const progress = (paid / debt.principal) * 100;
|
||||||
|
const plan = repaymentPlan.find((p) => p.debt.$id === debt.$id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={debt.$id}>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{debt.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{debt.interest_rate}% p.a. · {debt.term_months}m
|
||||||
|
{debt.is_auto_calculated && (
|
||||||
|
<span className="ml-1 text-xs text-accent">(auto EMI)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-mono font-semibold text-danger">
|
||||||
|
{formatCurrency(debt.remaining_balance)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{formatCurrency(debt.monthly_payment)}/mo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={progress} color="#22c55e" showPercent />
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Paid: {formatCurrency(paid)} of {formatCurrency(debt.principal)}
|
||||||
|
</p>
|
||||||
|
{plan && plan.monthsToPayoff < 9999 && (
|
||||||
|
<p className="text-xs text-accent mt-1">
|
||||||
|
Paid off in: {monthsToReadable(plan.monthsToPayoff)}
|
||||||
|
{plan.suggestedExtra > 0 && (
|
||||||
|
<span className="text-success ml-1">
|
||||||
|
(+{formatCurrency(plan.suggestedExtra)} extra)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingDebt(debt)}
|
||||||
|
className="text-xs text-slate-400 hover:text-white px-2"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingDebt(debt)}
|
||||||
|
className="text-xs text-slate-500 hover:text-danger px-2"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto repayment plan */}
|
||||||
|
{extraAvailable > 0 && debts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Auto Repayment Plan" />
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start gap-2 mb-3">
|
||||||
|
<AlertCircle size={16} className="text-accent shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
You have <span className="text-white font-medium">{formatCurrency(extraAvailable)}</span> available
|
||||||
|
after expenses. Avalanche method suggests applying extra to highest-interest debt first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{repaymentPlan.map((plan) => (
|
||||||
|
<div key={plan.debt.$id} className="py-2 border-t border-slate-700 first:border-0">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-white">{plan.debt.name}</span>
|
||||||
|
<span className="text-sm font-mono text-white">
|
||||||
|
{formatCurrency(plan.debt.monthly_payment + plan.suggestedExtra)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-0.5">
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{plan.suggestedExtra > 0 && (
|
||||||
|
<span className="text-success">+{formatCurrency(plan.suggestedExtra)} extra · </span>
|
||||||
|
)}
|
||||||
|
Payoff in {monthsToReadable(plan.monthsToPayoff)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Interest: {formatCurrency(plan.totalInterest)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={showModal} onClose={() => setShowModal(false)} title="Add Debt" size="lg">
|
||||||
|
<DebtForm onSave={handleCreate} onCancel={() => setShowModal(false)} />
|
||||||
|
</Modal>
|
||||||
|
<Modal open={!!editingDebt} onClose={() => setEditingDebt(null)} title="Edit Debt" size="lg">
|
||||||
|
<DebtForm initial={editingDebt ?? undefined} onSave={handleUpdate} onCancel={() => setEditingDebt(null)} />
|
||||||
|
</Modal>
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!deletingDebt}
|
||||||
|
onClose={() => setDeletingDebt(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete debt?"
|
||||||
|
message={`Remove "${deletingDebt?.name}" from your debts?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/pages/LoanCalculator.tsx
Normal file
224
src/pages/LoanCalculator.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Calculator, Info } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { buildMonthlySnapshot, calculateEMI, maxLoanPrincipal } from '../lib/calculations';
|
||||||
|
import { formatCurrency, formatPercent } from '../lib/utils';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, SectionHeader, StatCard } from '../components/ui/Card';
|
||||||
|
import { Input, Select } from '../components/ui/Input';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
|
||||||
|
export function LoanCalculator() {
|
||||||
|
const { balanceSheet, incomes, buckets, debts } = useStore();
|
||||||
|
const [annualRate, setAnnualRate] = useState('8.5');
|
||||||
|
const [termMonths, setTermMonths] = useState('120');
|
||||||
|
const [desiredLoan, setDesiredLoan] = useState('');
|
||||||
|
const [includeExtra, setIncludeExtra] = useState('0');
|
||||||
|
const [mode, setMode] = useState<'max' | 'check'>('max');
|
||||||
|
|
||||||
|
const snapshot = buildMonthlySnapshot(balanceSheet, incomes, buckets, debts);
|
||||||
|
const available = snapshot.available;
|
||||||
|
const extraAvailableForLoan = available + (parseFloat(includeExtra) || 0);
|
||||||
|
const rateNum = parseFloat(annualRate) || 0;
|
||||||
|
const termNum = parseInt(termMonths) || 1;
|
||||||
|
|
||||||
|
// Max loan calc
|
||||||
|
const maxPrincipal =
|
||||||
|
extraAvailableForLoan > 0 ? maxLoanPrincipal(extraAvailableForLoan, rateNum, termNum) : 0;
|
||||||
|
|
||||||
|
// Check mode: desired loan EMI
|
||||||
|
const desiredNum = parseFloat(desiredLoan) || 0;
|
||||||
|
const desiredEMI = desiredNum > 0 ? calculateEMI(desiredNum, rateNum, termNum) : 0;
|
||||||
|
const canAfford = desiredEMI <= extraAvailableForLoan;
|
||||||
|
const shortfall = desiredEMI - extraAvailableForLoan;
|
||||||
|
|
||||||
|
// Total interest
|
||||||
|
const totalPayment = desiredEMI > 0 ? desiredEMI * termNum : extraAvailableForLoan * termNum;
|
||||||
|
const totalInterest = totalPayment - (mode === 'check' ? desiredNum : maxPrincipal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header title="Loan Calculator" back />
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{/* Available monthly */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info size={16} className="text-accent shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Available after income, buffer, buckets & debt payments:
|
||||||
|
</p>
|
||||||
|
<p className={`text-xl font-mono font-bold mt-1 ${available >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{formatCurrency(available)} / month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Snapshot breakdown */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StatCard label="Income" value={formatCurrency(snapshot.totalMonthlyIncome)} color="success" />
|
||||||
|
<StatCard label="Buffer" value={formatCurrency(snapshot.bufferAmount)} />
|
||||||
|
<StatCard label="Bucket Goals" value={formatCurrency(snapshot.totalBucketAllocation)} />
|
||||||
|
<StatCard label="Debt Payments" value={formatCurrency(snapshot.debtPayments)} color="warning" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['max', 'check'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`flex-1 py-2.5 text-sm rounded-xl font-medium transition-colors ${
|
||||||
|
mode === m ? 'bg-accent text-white' : 'bg-surface-raised text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'max' ? 'Max Affordable Loan' : 'Check a Loan'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parameters */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Loan Parameters" />
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="Interest rate (p.a.)"
|
||||||
|
type="number"
|
||||||
|
placeholder="8.5"
|
||||||
|
suffix="%"
|
||||||
|
value={annualRate}
|
||||||
|
onChange={(e) => setAnnualRate(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<label className="text-sm font-medium text-slate-300">Term</label>
|
||||||
|
<select
|
||||||
|
value={termMonths}
|
||||||
|
onChange={(e) => setTermMonths(e.target.value)}
|
||||||
|
className="w-full rounded-xl bg-surface-raised border border-slate-600 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent py-2.5 px-3"
|
||||||
|
>
|
||||||
|
<option value="12">1 year (12m)</option>
|
||||||
|
<option value="24">2 years (24m)</option>
|
||||||
|
<option value="36">3 years (36m)</option>
|
||||||
|
<option value="60">5 years (60m)</option>
|
||||||
|
<option value="84">7 years (84m)</option>
|
||||||
|
<option value="120">10 years (120m)</option>
|
||||||
|
<option value="180">15 years (180m)</option>
|
||||||
|
<option value="240">20 years (240m)</option>
|
||||||
|
<option value="300">25 years (300m)</option>
|
||||||
|
<option value="360">30 years (360m)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Extra monthly capacity (optional)"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
prefix="+"
|
||||||
|
suffix="$/mo"
|
||||||
|
value={includeExtra}
|
||||||
|
onChange={(e) => setIncludeExtra(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
hint="Income you'd free up specifically for this loan"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === 'check' && (
|
||||||
|
<Input
|
||||||
|
label="Desired loan amount"
|
||||||
|
type="number"
|
||||||
|
placeholder="50000"
|
||||||
|
prefix="$"
|
||||||
|
value={desiredLoan}
|
||||||
|
onChange={(e) => setDesiredLoan(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Results" />
|
||||||
|
{mode === 'max' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card>
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Max Affordable Loan</p>
|
||||||
|
<p className="text-3xl font-bold font-mono text-accent">
|
||||||
|
{formatCurrency(maxPrincipal)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
At {rateNum}% p.a. for {termNum} months
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StatCard
|
||||||
|
label="Monthly Payment"
|
||||||
|
value={formatCurrency(extraAvailableForLoan)}
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total Interest"
|
||||||
|
value={formatCurrency(Math.max(0, totalInterest))}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{extraAvailableForLoan <= 0 && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-danger text-center py-2">
|
||||||
|
No capacity for a new loan. Review your expenses or income.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{desiredNum > 0 ? (
|
||||||
|
<>
|
||||||
|
<Card className={canAfford ? 'border border-success/40' : 'border border-danger/40'}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className={`text-4xl mb-2 ${canAfford ? 'text-success' : 'text-danger'}`}
|
||||||
|
>
|
||||||
|
{canAfford ? '✓' : '✗'}
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-semibold ${canAfford ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{canAfford ? 'You can afford this loan' : 'Loan may be too large'}
|
||||||
|
</p>
|
||||||
|
{!canAfford && (
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Shortfall: {formatCurrency(shortfall)}/mo
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StatCard label="Monthly EMI" value={formatCurrency(desiredEMI)} color={canAfford ? 'success' : 'danger'} />
|
||||||
|
<StatCard label="Available" value={formatCurrency(extraAvailableForLoan)} color="info" />
|
||||||
|
<StatCard label="Total Payment" value={formatCurrency(desiredEMI * termNum)} />
|
||||||
|
<StatCard
|
||||||
|
label="Total Interest"
|
||||||
|
value={formatCurrency(Math.max(0, desiredEMI * termNum - desiredNum))}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-400 text-center py-3">
|
||||||
|
Enter a loan amount to check affordability.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/pages/More.tsx
Normal file
85
src/pages/More.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Calculator, Settings, LogOut, ChevronRight } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { logout } from '../appwrite/auth';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
|
||||||
|
export function More() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, setUser } = useStore();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
setUser(null);
|
||||||
|
navigate('/auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
icon: Calculator,
|
||||||
|
label: 'Loan Calculator',
|
||||||
|
sub: 'Max affordable loan based on your budget',
|
||||||
|
onClick: () => navigate('/loan-calculator'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
label: 'Settings',
|
||||||
|
sub: 'Appwrite endpoint & preferences',
|
||||||
|
onClick: () => navigate('/settings'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header title="More" />
|
||||||
|
<div className="px-4 py-4 space-y-4">
|
||||||
|
{/* User info */}
|
||||||
|
{user && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/30 flex items-center justify-center font-bold text-accent text-lg">
|
||||||
|
{user.name?.[0]?.toUpperCase() ?? user.email[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{user.name || 'User'}</p>
|
||||||
|
<p className="text-xs text-slate-400">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation items */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map(({ icon: Icon, label, sub, onClick }) => (
|
||||||
|
<Card key={label} onClick={onClick}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-surface-raised flex items-center justify-center shrink-0">
|
||||||
|
<Icon size={18} className="text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-white">{label}</p>
|
||||||
|
<p className="text-xs text-slate-400">{sub}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-slate-500" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<Card onClick={handleLogout}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-danger/20 flex items-center justify-center shrink-0">
|
||||||
|
<LogOut size={18} className="text-danger" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-danger">Sign Out</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-600 text-center pt-2">BudgetWise v1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/pages/Settings.tsx
Normal file
128
src/pages/Settings.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ExternalLink, Database, RefreshCw } from 'lucide-react';
|
||||||
|
import { Layout } from '../components/layout/Layout';
|
||||||
|
import { Header } from '../components/layout/Header';
|
||||||
|
import { Card, SectionHeader } from '../components/ui/Card';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const [endpoint, setEndpoint] = useState(
|
||||||
|
import.meta.env.VITE_APPWRITE_ENDPOINT ?? '',
|
||||||
|
);
|
||||||
|
const [projectId, setProjectId] = useState(
|
||||||
|
import.meta.env.VITE_APPWRITE_PROJECT_ID ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header title="Settings" back />
|
||||||
|
<div className="px-4 py-4 space-y-5">
|
||||||
|
{/* Appwrite config */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Appwrite Configuration" />
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start gap-2 bg-accent/10 rounded-xl p-3">
|
||||||
|
<Database size={16} className="text-accent shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-slate-300">
|
||||||
|
BudgetWise uses a self-hosted Appwrite instance for data sync. Configure your
|
||||||
|
instance below. Changes take effect after rebuilding the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Appwrite Endpoint"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://your-appwrite.com/v1"
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
hint="Set VITE_APPWRITE_ENDPOINT in your .env file"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Project ID"
|
||||||
|
placeholder="your-project-id"
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
hint="Set VITE_APPWRITE_PROJECT_ID in your .env file"
|
||||||
|
/>
|
||||||
|
<div className="bg-surface-raised rounded-xl p-3 text-xs text-slate-400 space-y-1 font-mono">
|
||||||
|
<p className="text-slate-300 font-sans font-medium mb-2">Your .env file:</p>
|
||||||
|
<p>VITE_APPWRITE_ENDPOINT={endpoint || 'https://...'}</p>
|
||||||
|
<p>VITE_APPWRITE_PROJECT_ID={projectId || 'your-id'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup instructions */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="First-time Setup" />
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent flex items-center justify-center text-xs font-bold text-white shrink-0">1</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Install Appwrite</p>
|
||||||
|
<p className="text-xs text-slate-400">Self-host Appwrite on your server (Docker recommended)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent flex items-center justify-center text-xs font-bold text-white shrink-0">2</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Create a project</p>
|
||||||
|
<p className="text-xs text-slate-400">Create a new project in Appwrite console, note the Project ID</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent flex items-center justify-center text-xs font-bold text-white shrink-0">3</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Run database setup</p>
|
||||||
|
<p className="text-xs text-slate-400 font-mono bg-surface rounded-lg px-2 py-1 mt-1">
|
||||||
|
npm run setup:appwrite
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent flex items-center justify-center text-xs font-bold text-white shrink-0">4</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Configure .env & build</p>
|
||||||
|
<p className="text-xs text-slate-400">Set endpoint & project ID, then run npm run build</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add to home screen hint */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Install on Android" />
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<RefreshCw size={16} className="text-info shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white mb-1">Add to Home Screen</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Open BudgetWise in Chrome on Android → tap the three-dot menu → "Add to Home screen".
|
||||||
|
The app will then run in standalone mode like a native app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<a
|
||||||
|
href="https://appwrite.io/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 bg-surface rounded-2xl px-4 py-3 hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} className="text-slate-400" />
|
||||||
|
<span className="text-sm text-white">Appwrite Documentation</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/store/index.ts
Normal file
97
src/store/index.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Models } from 'appwrite';
|
||||||
|
import type { BalanceSheet, Income, Bucket, Debt, Transaction } from '../types';
|
||||||
|
import { currentMonthYear } from '../lib/utils';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// Auth
|
||||||
|
user: Models.User<Models.Preferences> | null;
|
||||||
|
setUser: (user: Models.User<Models.Preferences> | null) => void;
|
||||||
|
|
||||||
|
// Selected month/year for balance sheet view
|
||||||
|
selectedMonth: number;
|
||||||
|
selectedYear: number;
|
||||||
|
setSelectedPeriod: (month: number, year: number) => void;
|
||||||
|
|
||||||
|
// Data
|
||||||
|
balanceSheet: BalanceSheet | null;
|
||||||
|
setBalanceSheet: (bs: BalanceSheet | null) => void;
|
||||||
|
|
||||||
|
incomes: Income[];
|
||||||
|
setIncomes: (incomes: Income[]) => void;
|
||||||
|
addIncome: (income: Income) => void;
|
||||||
|
removeIncome: (id: string) => void;
|
||||||
|
updateIncomeItem: (id: string, updated: Income) => void;
|
||||||
|
|
||||||
|
buckets: Bucket[];
|
||||||
|
setBuckets: (buckets: Bucket[]) => void;
|
||||||
|
addBucket: (bucket: Bucket) => void;
|
||||||
|
removeBucket: (id: string) => void;
|
||||||
|
updateBucketItem: (id: string, updated: Bucket) => void;
|
||||||
|
|
||||||
|
debts: Debt[];
|
||||||
|
setDebts: (debts: Debt[]) => void;
|
||||||
|
addDebt: (debt: Debt) => void;
|
||||||
|
removeDebt: (id: string) => void;
|
||||||
|
updateDebtItem: (id: string, updated: Debt) => void;
|
||||||
|
|
||||||
|
transactions: Transaction[];
|
||||||
|
setTransactions: (txns: Transaction[]) => void;
|
||||||
|
addTransaction: (txn: Transaction) => void;
|
||||||
|
removeTransaction: (id: string) => void;
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { month, year } = currentMonthYear();
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
|
||||||
|
selectedMonth: month,
|
||||||
|
selectedYear: year,
|
||||||
|
setSelectedPeriod: (selectedMonth, selectedYear) =>
|
||||||
|
set({ selectedMonth, selectedYear }),
|
||||||
|
|
||||||
|
balanceSheet: null,
|
||||||
|
setBalanceSheet: (balanceSheet) => set({ balanceSheet }),
|
||||||
|
|
||||||
|
incomes: [],
|
||||||
|
setIncomes: (incomes) => set({ incomes }),
|
||||||
|
addIncome: (income) => set((s) => ({ incomes: [...s.incomes, income] })),
|
||||||
|
removeIncome: (id) =>
|
||||||
|
set((s) => ({ incomes: s.incomes.filter((i) => i.$id !== id) })),
|
||||||
|
updateIncomeItem: (id, updated) =>
|
||||||
|
set((s) => ({ incomes: s.incomes.map((i) => (i.$id === id ? updated : i)) })),
|
||||||
|
|
||||||
|
buckets: [],
|
||||||
|
setBuckets: (buckets) => set({ buckets }),
|
||||||
|
addBucket: (bucket) => set((s) => ({ buckets: [...s.buckets, bucket] })),
|
||||||
|
removeBucket: (id) =>
|
||||||
|
set((s) => ({ buckets: s.buckets.filter((b) => b.$id !== id) })),
|
||||||
|
updateBucketItem: (id, updated) =>
|
||||||
|
set((s) => ({
|
||||||
|
buckets: s.buckets.map((b) => (b.$id === id ? updated : b)),
|
||||||
|
})),
|
||||||
|
|
||||||
|
debts: [],
|
||||||
|
setDebts: (debts) => set({ debts }),
|
||||||
|
addDebt: (debt) => set((s) => ({ debts: [...s.debts, debt] })),
|
||||||
|
removeDebt: (id) =>
|
||||||
|
set((s) => ({ debts: s.debts.filter((d) => d.$id !== id) })),
|
||||||
|
updateDebtItem: (id, updated) =>
|
||||||
|
set((s) => ({ debts: s.debts.map((d) => (d.$id === id ? updated : d)) })),
|
||||||
|
|
||||||
|
transactions: [],
|
||||||
|
setTransactions: (transactions) => set({ transactions }),
|
||||||
|
addTransaction: (txn) =>
|
||||||
|
set((s) => ({ transactions: [txn, ...s.transactions] })),
|
||||||
|
removeTransaction: (id) =>
|
||||||
|
set((s) => ({ transactions: s.transactions.filter((t) => t.$id !== id) })),
|
||||||
|
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
}));
|
||||||
109
src/types/index.ts
Normal file
109
src/types/index.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// ─── Appwrite document base ──────────────────────────────────────────────────
|
||||||
|
export interface AppwriteDocument {
|
||||||
|
$id: string;
|
||||||
|
$createdAt: string;
|
||||||
|
$updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Monthly Balance Sheet ────────────────────────────────────────────────────
|
||||||
|
export interface BalanceSheet extends AppwriteDocument {
|
||||||
|
month: number; // 1-12
|
||||||
|
year: number;
|
||||||
|
buffer_type: 'amount' | 'percent';
|
||||||
|
buffer_value: number;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Income ───────────────────────────────────────────────────────────────────
|
||||||
|
export interface Income extends AppwriteDocument {
|
||||||
|
balance_sheet_id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
frequency: 'monthly' | 'yearly';
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bucket ───────────────────────────────────────────────────────────────────
|
||||||
|
export type BucketType = 'regular' | 'savings' | 'investment';
|
||||||
|
|
||||||
|
export interface Bucket extends AppwriteDocument {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: BucketType;
|
||||||
|
current_balance: number;
|
||||||
|
// Goal settings
|
||||||
|
goal_amount: number; // 0 = no goal
|
||||||
|
goal_type: 'amount' | 'percent'; // percent of monthly income
|
||||||
|
goal_frequency: 'monthly' | 'yearly';
|
||||||
|
// Investment specific
|
||||||
|
return_percent: number; // 0 = not an investment
|
||||||
|
return_frequency: 'monthly' | 'yearly';
|
||||||
|
color: string;
|
||||||
|
sort_order: number;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Debt ─────────────────────────────────────────────────────────────────────
|
||||||
|
export interface Debt extends AppwriteDocument {
|
||||||
|
name: string;
|
||||||
|
principal: number;
|
||||||
|
remaining_balance: number;
|
||||||
|
interest_rate: number; // annual %
|
||||||
|
interest_frequency: 'monthly' | 'yearly';
|
||||||
|
term_months: number;
|
||||||
|
monthly_payment: number;
|
||||||
|
is_auto_calculated: boolean;
|
||||||
|
start_date: string; // ISO date string
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction ──────────────────────────────────────────────────────────────
|
||||||
|
export interface Transaction extends AppwriteDocument {
|
||||||
|
bucket_id: string;
|
||||||
|
type: 'deposit' | 'withdrawal';
|
||||||
|
amount: number;
|
||||||
|
date: string; // ISO date string
|
||||||
|
notes: string;
|
||||||
|
balance_after: number;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Computed / ViewModel types ───────────────────────────────────────────────
|
||||||
|
export interface MonthlySnapshot {
|
||||||
|
balanceSheet: BalanceSheet | null;
|
||||||
|
incomes: Income[];
|
||||||
|
totalMonthlyIncome: number;
|
||||||
|
bufferAmount: number;
|
||||||
|
bucketAllocations: BucketAllocation[];
|
||||||
|
totalBucketAllocation: number;
|
||||||
|
debtPayments: number;
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketAllocation {
|
||||||
|
bucket: Bucket;
|
||||||
|
monthlyAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoanAnalysis {
|
||||||
|
maxAffordablePrincipal: number;
|
||||||
|
monthlyPaymentForMax: number;
|
||||||
|
availableForLoan: number;
|
||||||
|
rateMonthly: number;
|
||||||
|
termMonths: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtRepaymentPlan {
|
||||||
|
debt: Debt;
|
||||||
|
suggestedExtra: number;
|
||||||
|
monthsToPayoff: number;
|
||||||
|
totalInterest: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestmentProjection {
|
||||||
|
bucket: Bucket;
|
||||||
|
monthlyReturn: number;
|
||||||
|
projectedValue1Y: number;
|
||||||
|
projectedValue5Y: number;
|
||||||
|
projectedValue10Y: number;
|
||||||
|
}
|
||||||
29
tailwind.config.js
Normal file
29
tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
DEFAULT: '#1e293b',
|
||||||
|
raised: '#334155',
|
||||||
|
overlay: '#0f172a',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#6366f1',
|
||||||
|
hover: '#4f46e5',
|
||||||
|
muted: '#312e81',
|
||||||
|
},
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
danger: '#ef4444',
|
||||||
|
info: '#3b82f6',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
58
vite.config.ts
Normal file
58
vite.config.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['icon.svg', 'favicon.ico'],
|
||||||
|
manifest: {
|
||||||
|
name: 'BudgetWise',
|
||||||
|
short_name: 'BudgetWise',
|
||||||
|
description: 'Personal budgeting app with Appwrite sync',
|
||||||
|
theme_color: '#0f172a',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
start_url: '/',
|
||||||
|
id: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: ({ url }) => url.href.includes('/v1/'),
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'appwrite-api',
|
||||||
|
networkTimeoutSeconds: 10,
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user