chore(cleanup): remove legacy/public HTML files, retire /legacy route, update docs and About page
This commit is contained in:
parent
c6708982a9
commit
d0835b86ab
|
|
@ -5,6 +5,10 @@
|
||||||
|
|
||||||
- **Cents Migration Stage 2 — schema flip to integer cents** — The 12 dollar (REAL) columns across 8 tables defined in the cents-migration plan are now integer cents at rest. Migration v1.03 converts and back-fills existing rows; the schema, ~288 query sites in routes and services, CSV/spreadsheet import inserts, `userDbImportService` unit detection, and test fixtures are all cents-aware. CSV export divides by 100 for display. Float dollars are eliminated before the data grows further, and the units now match SimpleFIN transactions/accounts (already cents). Stage 1's `utils/money.js` remains the single source of truth for arithmetic. The full plan, migration SQL, and verification checklist live in `docs/cents-migration-plan.md`.
|
- **Cents Migration Stage 2 — schema flip to integer cents** — The 12 dollar (REAL) columns across 8 tables defined in the cents-migration plan are now integer cents at rest. Migration v1.03 converts and back-fills existing rows; the schema, ~288 query sites in routes and services, CSV/spreadsheet import inserts, `userDbImportService` unit detection, and test fixtures are all cents-aware. CSV export divides by 100 for display. Float dollars are eliminated before the data grows further, and the units now match SimpleFIN transactions/accounts (already cents). Stage 1's `utils/money.js` remains the single source of truth for arithmetic. The full plan, migration SQL, and verification checklist live in `docs/cents-migration-plan.md`.
|
||||||
|
|
||||||
|
### 🧹 Cleanup
|
||||||
|
|
||||||
|
- **Retired static UI removed** — Deleted the old `legacy/` and root `public/` static UI copies, including the broken duplicate `public/js/api.js` client. The server now returns `410 Gone` for `/legacy` instead of serving stale assets, the hidden About-page legacy link is gone, and current docs no longer list the retired static UI as part of the runtime structure.
|
||||||
|
|
||||||
## v0.37.0
|
## v0.37.0
|
||||||
|
|
||||||
### ✨ Added
|
### ✨ Added
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,6 @@ bill-tracker/
|
||||||
| `-- pages/ # Route pages
|
| `-- pages/ # Route pages
|
||||||
|-- db/ # SQLite schema, migrations, and database helpers
|
|-- db/ # SQLite schema, migrations, and database helpers
|
||||||
|-- docs/ # Technical references and README screenshots
|
|-- docs/ # Technical references and README screenshots
|
||||||
|-- legacy/ # Legacy static UI retained for reference
|
|
||||||
|-- middleware/ # Auth, CSRF, rate limit, security, and error middleware
|
|-- middleware/ # Auth, CSRF, rate limit, security, and error middleware
|
||||||
|-- routes/ # Express API route handlers
|
|-- routes/ # Express API route handlers
|
||||||
|-- scripts/ # Utility, migration, deployment, and smoke-test scripts
|
|-- scripts/ # Utility, migration, deployment, and smoke-test scripts
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,11 @@
|
||||||
#### CSRF Token Handling Fixes
|
#### CSRF Token Handling Fixes
|
||||||
**Issue:** Create user and other state-changing requests failing with CSRF errors.
|
**Issue:** Create user and other state-changing requests failing with CSRF errors.
|
||||||
|
|
||||||
**Root Cause:** Legacy and public API clients not sending CSRF tokens.
|
**Root Cause:** Retired static API clients were not sending CSRF tokens.
|
||||||
|
|
||||||
**Fixes Applied:**
|
**Fixes Applied:**
|
||||||
1. `client/api.js` - ✅ Already correct
|
1. `client/api.js` - ✅ Already correct
|
||||||
2. `legacy/js/api.js` - ✅ Fixed - added `credentials: 'include'` and CSRF token extraction
|
2. Retired `legacy/js/api.js` and `public/js/api.js` - ✅ Removed with the static legacy UI cleanup
|
||||||
3. `public/js/api.js` - ✅ Fixed - added `credentials: 'include'` and CSRF token extraction
|
|
||||||
|
|
||||||
#### CSRF Cookie httpOnly Configuration (Neo) - 2026-05-08
|
#### CSRF Cookie httpOnly Configuration (Neo) - 2026-05-08
|
||||||
**Issue:** Need configurable CSRF cookie httpOnly setting for SPA vs secure mode.
|
**Issue:** Need configurable CSRF cookie httpOnly setting for SPA vs secure mode.
|
||||||
|
|
@ -922,4 +921,3 @@ Command failed: cd /home/kaspa/.openclaw/Projects/bill-tracker && npx playwright
|
||||||
The notes feature is implemented as **per-bill AND per-month**. Each bill has its own notes field, and each month has its own separate notes.
|
The notes feature is implemented as **per-bill AND per-month**. Each bill has its own notes field, and each month has its own separate notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,20 +156,6 @@ export default function AboutPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Easter egg — barely visible, reveals on hover for curious explorers */}
|
|
||||||
<div className="flex justify-center pt-10 pb-4">
|
|
||||||
<a
|
|
||||||
href="/legacy"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[10px] text-muted-foreground/10 hover:text-muted-foreground/50 transition-colors duration-1000 select-none tracking-widest uppercase"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
remember when
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ Global middleware order:
|
||||||
4. `cookieParser()`
|
4. `cookieParser()`
|
||||||
5. `csrfTokenProvider`
|
5. `csrfTokenProvider`
|
||||||
6. mounted API routers with route-level rate-limit/auth/CSRF middleware
|
6. mounted API routers with route-level rate-limit/auth/CSRF middleware
|
||||||
7. static `legacy/`, redirect `/login.html` to `/login`, static `dist/`
|
7. retired `/legacy` route returns 410, redirect `/login.html` to `/login`, static `dist/`
|
||||||
8. SPA fallback `GET *` serving `dist/index.html` after ensuring a CSRF token cookie
|
8. SPA fallback `GET *` serving `dist/index.html` after ensuring a CSRF token cookie
|
||||||
9. `errorFormatter`
|
9. `errorFormatter`
|
||||||
10. final JSON error handler for malformed JSON/body size/runtime errors
|
10. final JSON error handler for malformed JSON/body size/runtime errors
|
||||||
|
|
|
||||||
|
|
@ -1,766 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bill Tracker — Admin</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
:root {
|
|
||||||
--bg: #0d1526;
|
|
||||||
--surface: #162236;
|
|
||||||
--surface-2: #1c2d47;
|
|
||||||
--surface-3: #223558;
|
|
||||||
--border: rgba(255,255,255,0.07);
|
|
||||||
--border-strong: rgba(255,255,255,0.14);
|
|
||||||
--text: #e2e8f0;
|
|
||||||
--text-muted: #8faab8;
|
|
||||||
--text-faint: #506070;
|
|
||||||
--primary: #6366f1;
|
|
||||||
--primary-hover: #4f46e5;
|
|
||||||
--primary-light: rgba(99,102,241,0.18);
|
|
||||||
--danger: #f43f5e;
|
|
||||||
--danger-light: rgba(244,63,94,0.15);
|
|
||||||
--success: #22d3a5;
|
|
||||||
--success-light: rgba(34,211,165,0.15);
|
|
||||||
--warning: #fb923c;
|
|
||||||
--warning-light: rgba(251,146,60,0.15);
|
|
||||||
--radius: 6px;
|
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
body { font-family: var(--font); font-size: 14px; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
||||||
header { background: #0a1120; color: var(--text); padding: 0 24px; height: 52px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); }
|
|
||||||
.logo { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.logo-icon { width: 28px; height: 28px; background: var(--primary); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 800; color: white; font-size: 13px; box-shadow: 0 0 10px rgba(99,102,241,0.35); }
|
|
||||||
.logo-text { font-weight: 700; font-size: 15px; color: var(--text); }
|
|
||||||
.admin-badge { background: var(--warning-light); color: var(--warning); border: 1px solid rgba(251,146,60,0.25); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; padding: 2px 7px; border-radius: 20px; margin-left: 4px; }
|
|
||||||
header .spacer { flex: 1; }
|
|
||||||
#admin-name { color: var(--text-faint); font-size: 13px; margin-right: 8px; }
|
|
||||||
.btn-logout { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border-strong); padding: 6px 12px; border-radius: var(--radius); font-size: 13px; cursor: pointer; transition: background .15s, color .15s; }
|
|
||||||
.btn-logout:hover { background: var(--surface-3); color: var(--text); }
|
|
||||||
main { max-width: 700px; margin: 32px auto; padding: 0 20px; }
|
|
||||||
|
|
||||||
/* ── Onboarding wizard ── */
|
|
||||||
#onboarding { display: none; }
|
|
||||||
.wizard-step { display: none; }
|
|
||||||
.wizard-step.active { display: block; }
|
|
||||||
.wizard-card { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border); }
|
|
||||||
.wizard-icon { font-size: 32px; margin-bottom: 16px; }
|
|
||||||
.wizard-card h2 { font-size: 20px; font-weight: 700; margin-bottom: 8px; color: var(--text); }
|
|
||||||
.wizard-card .sub { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; line-height: 1.6; }
|
|
||||||
.capability-list { list-style: none; margin: 0 0 24px; }
|
|
||||||
.capability-list li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
|
||||||
.capability-list li:last-child { border-bottom: none; }
|
|
||||||
.cap-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
|
|
||||||
.cap-can { color: var(--success); }
|
|
||||||
.cap-cant { color: var(--danger); }
|
|
||||||
.cap-text { line-height: 1.5; }
|
|
||||||
.cap-text strong { display: block; color: var(--text); }
|
|
||||||
.cap-text span { color: var(--text-muted); font-size: 13px; }
|
|
||||||
|
|
||||||
/* ── Normal admin panel ── */
|
|
||||||
#panel { display: none; }
|
|
||||||
.notice { background: var(--warning-light); border: 1px solid rgba(251,146,60,0.25); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px; }
|
|
||||||
.notice strong { color: var(--warning); display: block; margin-bottom: 4px; font-size: 13px; }
|
|
||||||
.notice ul { margin: 8px 0 0 20px; font-size: 13px; color: var(--text-muted); }
|
|
||||||
.notice li { margin-bottom: 2px; }
|
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border); }
|
|
||||||
.card h2 { font-size: 15px; font-weight: 700; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 6px 10px; border-bottom: 1px solid var(--border-strong); background: var(--surface-2); }
|
|
||||||
td { padding: 10px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text); }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr:hover td { background: var(--surface-2); }
|
|
||||||
.role-badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
|
||||||
.role-admin { background: var(--warning-light); color: var(--warning); }
|
|
||||||
.role-user { background: var(--primary-light); color: var(--primary); }
|
|
||||||
|
|
||||||
/* ── Shared form/button styles ── */
|
|
||||||
.btn { display: inline-flex; align-items: center; gap: 4px; padding: 8px 16px; border: none; border-radius: var(--radius); font-size: 13px; font-family: var(--font); font-weight: 600; cursor: pointer; transition: background .15s, box-shadow .15s; }
|
|
||||||
.btn-primary { background: var(--primary); color: white; }
|
|
||||||
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
|
|
||||||
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-strong); }
|
|
||||||
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-danger:hover:not(:disabled) { background: #e11d48; }
|
|
||||||
.btn-sm { padding: 4px 9px; font-size: 12px; }
|
|
||||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
||||||
.btn-full { width: 100%; justify-content: center; }
|
|
||||||
.add-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
.form-group label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
|
|
||||||
input[type="text"], input[type="password"], input[type="email"], input[type="number"], select { padding: 8px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius); font-size: 13px; font-family: var(--font); color: var(--text); background: var(--surface-2); width: 100%; transition: border-color .15s, box-shadow .15s; }
|
|
||||||
input::placeholder, select option[disabled] { color: var(--text-faint); }
|
|
||||||
select option { background: var(--surface-2); color: var(--text); }
|
|
||||||
input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
|
|
||||||
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); }
|
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
|
||||||
.err { color: var(--danger); font-size: 12px; margin-top: 6px; min-height: 16px; }
|
|
||||||
.ok { color: var(--success); font-size: 12px; margin-top: 6px; min-height: 16px; }
|
|
||||||
.reset-form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.reset-form input { min-width: 120px; padding: 4px 8px; font-size: 12px; }
|
|
||||||
.muted { color: var(--text-muted); font-size: 12px; }
|
|
||||||
code { background: var(--surface-3); color: #38bdf8; padding: 1px 5px; border-radius: 3px; font-size: 12px; font-family: 'SF Mono', monospace; }
|
|
||||||
#panel-status { font-size: 13px; margin-top: 4px; min-height: 18px; }
|
|
||||||
.step-dots { display: flex; gap: 6px; justify-content: center; margin-bottom: 24px; }
|
|
||||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--surface-3); transition: background .2s; }
|
|
||||||
.dot.active { background: var(--primary); }
|
|
||||||
|
|
||||||
/* Email settings form */
|
|
||||||
.smtp-form { display: grid; grid-template-columns: 220px 1fr; gap: 10px 16px; align-items: center; }
|
|
||||||
.smtp-form .row-label { font-size: 13px; font-weight: 500; color: var(--text); }
|
|
||||||
.smtp-form .row-sub { font-size: 11px; color: var(--text-faint); margin-top:1px; }
|
|
||||||
.smtp-form .row-ctrl { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.smtp-divider { grid-column: 1/-1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
|
||||||
.pw-wrap { position: relative; flex: 1; }
|
|
||||||
.pw-wrap input { padding-right: 36px; }
|
|
||||||
.pw-toggle { position:absolute; right:8px; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; color:var(--text-faint); font-size:16px; line-height:1; }
|
|
||||||
.pw-toggle:hover { color: var(--text); }
|
|
||||||
.enabled-badge { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:20px; font-size:11px; font-weight:700; }
|
|
||||||
.badge-on { background:var(--success-light); color:var(--success); }
|
|
||||||
.badge-off { background:var(--surface-2); color:var(--text-muted); }
|
|
||||||
.test-row { grid-column:1/-1; display:flex; gap:8px; align-items:center; margin-top:4px; }
|
|
||||||
.notif-actions { grid-column:1/-1; display:flex; gap:8px; justify-content:flex-end; margin-top:8px; }
|
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
|
||||||
::-webkit-scrollbar { width: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<div class="logo-icon">$</div>
|
|
||||||
<span class="logo-text">BillTracker</span>
|
|
||||||
<span class="admin-badge">Admin</span>
|
|
||||||
</div>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<span id="admin-name"></span>
|
|
||||||
<button class="btn-logout" id="logout-btn">Sign Out</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ── Onboarding wizard (shown when no users exist yet) ───────────────── -->
|
|
||||||
<div id="onboarding">
|
|
||||||
<main>
|
|
||||||
<div class="step-dots">
|
|
||||||
<div class="dot active" id="dot-1"></div>
|
|
||||||
<div class="dot" id="dot-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 1: Explain the admin account -->
|
|
||||||
<div class="wizard-step active" id="step-1">
|
|
||||||
<div class="wizard-card">
|
|
||||||
<div class="wizard-icon">🔒</div>
|
|
||||||
<h2>Welcome to Bill Tracker</h2>
|
|
||||||
<p class="sub">
|
|
||||||
You're logged in as the <strong>admin</strong>. Before you get started,
|
|
||||||
here's exactly what this account can and cannot do.
|
|
||||||
</p>
|
|
||||||
<ul class="capability-list">
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-can">✓</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Create user accounts</strong>
|
|
||||||
<span>You control who can log in.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-can">✓</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Reset user passwords</strong>
|
|
||||||
<span>If a user gets locked out, you can reset their password.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-cant">✗</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Cannot access any financial data</strong>
|
|
||||||
<span>Bills, payments, and tracker data are completely off-limits to this account — by design.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-cant">✗</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Cannot view account balances or history</strong>
|
|
||||||
<span>Your financial privacy is enforced at the API level, not just the UI.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-primary btn-full" id="step1-next">
|
|
||||||
Got it — create my user account →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Create first user -->
|
|
||||||
<div class="wizard-step" id="step-2">
|
|
||||||
<div class="wizard-card">
|
|
||||||
<div class="wizard-icon">👤</div>
|
|
||||||
<h2>Create Your User Account</h2>
|
|
||||||
<p class="sub">
|
|
||||||
This account will have full access to the tracker, bills, and payments.
|
|
||||||
Use this account for your day-to-day bill tracking.
|
|
||||||
</p>
|
|
||||||
<form id="onboarding-form">
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" id="ob-username" autocapitalize="none" minlength="3" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="grid-column:1/-1"><!-- spacer --></div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Password</label>
|
|
||||||
<input type="password" id="ob-password" minlength="8" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Confirm Password</label>
|
|
||||||
<input type="password" id="ob-confirm" minlength="8" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="err" id="ob-error"></div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-full" id="ob-submit">
|
|
||||||
Create Account & Go to Admin Panel
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-full" id="step2-back"
|
|
||||||
style="margin-top:8px">
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Normal admin panel ──────────────────────────────────────────────── -->
|
|
||||||
<div id="panel">
|
|
||||||
<main>
|
|
||||||
<div class="notice">
|
|
||||||
<strong>Admin Account Scope</strong>
|
|
||||||
<ul>
|
|
||||||
<li>You can create user accounts and reset passwords.</li>
|
|
||||||
<li>You cannot view, access, or modify any bills, payments, or financial data.</li>
|
|
||||||
<li>Users are informed that only their password can be reset by this account.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="email-notif-card">
|
|
||||||
<h2>Email Notifications</h2>
|
|
||||||
<div id="email-notif-content"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="auth-mode-card">
|
|
||||||
<h2>Login Mode</h2>
|
|
||||||
<div id="auth-mode-content"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Add User</h2>
|
|
||||||
<form class="add-form" id="add-user-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" id="new-username" autocapitalize="none" minlength="3" required style="min-width:160px">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Password</label>
|
|
||||||
<input type="password" id="new-password" minlength="8" required style="min-width:160px">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary" style="align-self:flex-end">Create User</button>
|
|
||||||
</form>
|
|
||||||
<div id="add-status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Users</h2>
|
|
||||||
<div id="users-table"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const res = await fetch('/api/auth/me');
|
|
||||||
if (!res.ok) { location.href = '/login.html'; return; }
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.user.role !== 'admin') { location.href = '/'; return; }
|
|
||||||
currentUser = data.user;
|
|
||||||
document.getElementById('admin-name').textContent = data.user.username;
|
|
||||||
|
|
||||||
const hasRes = await fetch('/api/admin/has-users');
|
|
||||||
const hasData = await hasRes.json();
|
|
||||||
|
|
||||||
if (!hasData.has_users) {
|
|
||||||
showOnboarding();
|
|
||||||
} else {
|
|
||||||
showPanel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Onboarding ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showOnboarding() {
|
|
||||||
document.getElementById('onboarding').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPanel() {
|
|
||||||
document.getElementById('onboarding').style.display = 'none';
|
|
||||||
document.getElementById('panel').style.display = 'block';
|
|
||||||
loadEmailNotif();
|
|
||||||
loadAuthMode();
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStep(n) {
|
|
||||||
document.querySelectorAll('.wizard-step').forEach((el, i) => {
|
|
||||||
el.classList.toggle('active', i + 1 === n);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.dot').forEach((el, i) => {
|
|
||||||
el.classList.toggle('active', i + 1 === n);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('step1-next').onclick = () => setStep(2);
|
|
||||||
document.getElementById('step2-back').onclick = () => setStep(1);
|
|
||||||
|
|
||||||
document.getElementById('onboarding-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const username = document.getElementById('ob-username').value.trim();
|
|
||||||
const password = document.getElementById('ob-password').value;
|
|
||||||
const confirm = document.getElementById('ob-confirm').value;
|
|
||||||
const errEl = document.getElementById('ob-error');
|
|
||||||
const btn = document.getElementById('ob-submit');
|
|
||||||
|
|
||||||
errEl.textContent = '';
|
|
||||||
|
|
||||||
if (password !== confirm) { errEl.textContent = 'Passwords do not match.'; return; }
|
|
||||||
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters.'; return; }
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Creating…';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
showPanel();
|
|
||||||
} catch (err) {
|
|
||||||
errEl.textContent = err.message;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Create Account & Go to Admin Panel';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Admin panel ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ── Email notifications (admin SMTP) ──────────────────────────────────────
|
|
||||||
|
|
||||||
let smtpSettings = {};
|
|
||||||
|
|
||||||
async function loadEmailNotif() {
|
|
||||||
const res = await fetch('/api/notifications/admin');
|
|
||||||
if (!res.ok) return;
|
|
||||||
smtpSettings = await res.json();
|
|
||||||
renderEmailNotif();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmailNotif() {
|
|
||||||
const s = smtpSettings;
|
|
||||||
const enabled = s.notify_smtp_enabled === 'true';
|
|
||||||
|
|
||||||
document.getElementById('email-notif-content').innerHTML = `
|
|
||||||
<div style="margin-bottom:16px;display:flex;align-items:center;gap:10px;">
|
|
||||||
<span class="enabled-badge ${enabled ? 'badge-on' : 'badge-off'}">
|
|
||||||
${enabled ? '● Enabled' : '○ Disabled'}
|
|
||||||
</span>
|
|
||||||
<span class="muted">${enabled ? 'Sending email notifications' : 'Email notifications are off'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="smtp-form" class="smtp-form" autocomplete="off">
|
|
||||||
|
|
||||||
<label class="row-label">Enable Agent</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="checkbox" id="s-enabled" ${enabled ? 'checked' : ''}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">Sender Name</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="text" id="s-sender-name" value="${esc(s.notify_sender_name)}" style="max-width:300px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Sender Address <span style="color:var(--danger)">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="email" id="s-sender-addr" value="${esc(s.notify_sender_address)}" style="max-width:300px" placeholder="from@example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
SMTP Host <span style="color:var(--danger)">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="text" id="s-host" value="${esc(s.notify_smtp_host)}" style="max-width:300px" placeholder="smtp.example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
SMTP Port <span style="color:var(--danger)">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="number" id="s-port" value="${esc(s.notify_smtp_port || '587')}" style="max-width:90px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Encryption Method <span style="color:var(--danger)">*</span>
|
|
||||||
<div class="row-sub">STARTTLS → port 587 · SSL/TLS → port 465</div>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<select id="s-encryption" style="max-width:240px">
|
|
||||||
<option value="starttls" ${s.notify_smtp_encryption==='starttls'?'selected':''}>STARTTLS</option>
|
|
||||||
<option value="ssl" ${s.notify_smtp_encryption==='ssl'?'selected':''}>SSL / TLS</option>
|
|
||||||
<option value="none" ${s.notify_smtp_encryption==='none'?'selected':''}>None</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">Allow Self-Signed Certificates</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="checkbox" id="s-self-signed" ${s.notify_smtp_self_signed==='true'?'checked':''}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">SMTP Username</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="text" id="s-user" value="${esc(s.notify_smtp_username)}" autocomplete="new-password" style="max-width:300px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">SMTP Password</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<div class="pw-wrap" style="max-width:300px">
|
|
||||||
<input type="password" id="s-pass" value="${esc(s.notify_smtp_password)}" autocomplete="new-password">
|
|
||||||
<button type="button" class="pw-toggle" onclick="togglePw('s-pass',this)">👁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Allow users to configure<br>their own notification email
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="checkbox" id="s-allow-user" ${s.notify_allow_user_config==='true'?'checked':''}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Global recipient email
|
|
||||||
<div class="row-sub">Used when user config is disabled</div>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="email" id="s-global-recipient" value="${esc(s.notify_global_recipient)}" style="max-width:300px" placeholder="you@example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-row">
|
|
||||||
<input type="email" id="s-test-to" placeholder="Send test to…" style="max-width:240px">
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="s-test-btn">Send Test Email</button>
|
|
||||||
<span id="s-test-status" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notif-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
<div id="smtp-save-status" style="text-align:right;margin-top:6px;font-size:13px;min-height:18px;"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('smtp-form').onsubmit = saveEmailNotif;
|
|
||||||
document.getElementById('s-test-btn').onclick = sendTestEmail;
|
|
||||||
|
|
||||||
// Auto-fill port on encryption change
|
|
||||||
document.getElementById('s-encryption').onchange = function() {
|
|
||||||
const portEl = document.getElementById('s-port');
|
|
||||||
if (this.value === 'ssl') portEl.value = '465';
|
|
||||||
else if (portEl.value === '465') portEl.value = '587';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEmailNotif(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
const payload = {
|
|
||||||
notify_smtp_enabled: document.getElementById('s-enabled').checked ? 'true' : 'false',
|
|
||||||
notify_sender_name: document.getElementById('s-sender-name').value,
|
|
||||||
notify_sender_address: document.getElementById('s-sender-addr').value,
|
|
||||||
notify_smtp_host: document.getElementById('s-host').value,
|
|
||||||
notify_smtp_port: document.getElementById('s-port').value,
|
|
||||||
notify_smtp_encryption: document.getElementById('s-encryption').value,
|
|
||||||
notify_smtp_self_signed: document.getElementById('s-self-signed').checked ? 'true' : 'false',
|
|
||||||
notify_smtp_username: document.getElementById('s-user').value,
|
|
||||||
notify_smtp_password: document.getElementById('s-pass').value,
|
|
||||||
notify_allow_user_config:document.getElementById('s-allow-user').checked ? 'true' : 'false',
|
|
||||||
notify_global_recipient: document.getElementById('s-global-recipient').value,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/admin', {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
|
||||||
smtpSettings = { ...smtpSettings, ...payload };
|
|
||||||
document.getElementById('smtp-save-status').innerHTML = '<span class="ok">Saved.</span>';
|
|
||||||
renderEmailNotif(); // re-render badge
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('smtp-save-status').innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTestEmail() {
|
|
||||||
const to = document.getElementById('s-test-to').value.trim();
|
|
||||||
const el = document.getElementById('s-test-status');
|
|
||||||
const btn = document.getElementById('s-test-btn');
|
|
||||||
if (!to) { el.textContent = 'Enter a recipient.'; return; }
|
|
||||||
btn.disabled = true;
|
|
||||||
el.textContent = 'Sending…';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/test', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ to }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
el.innerHTML = '<span class="ok">Sent!</span>';
|
|
||||||
} catch (err) {
|
|
||||||
el.innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePw(id, btn) {
|
|
||||||
const inp = document.getElementById(id);
|
|
||||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
|
||||||
btn.textContent = inp.type === 'password' ? '👁' : '🙈';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auth mode ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadAuthMode() {
|
|
||||||
const [modeRes, usersRes] = await Promise.all([
|
|
||||||
fetch('/api/admin/auth-mode'),
|
|
||||||
fetch('/api/admin/users'),
|
|
||||||
]);
|
|
||||||
const { auth_mode, default_user_id } = await modeRes.json();
|
|
||||||
const users = await usersRes.json();
|
|
||||||
const regularUsers = users.filter(u => u.role === 'user');
|
|
||||||
|
|
||||||
const el = document.getElementById('auth-mode-content');
|
|
||||||
|
|
||||||
if (auth_mode === 'single') {
|
|
||||||
const defaultUser = regularUsers.find(u => u.id == default_user_id);
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
||||||
<span style="background:var(--warning-light);color:var(--warning);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700">
|
|
||||||
Single-User Mode Active
|
|
||||||
</span>
|
|
||||||
<span class="muted">Auto-logged in as: <strong>${esc(defaultUser?.username || '(unknown)')}</strong></span>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="revert-multi-btn">Restore Login Requirement</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="margin-top:10px;font-size:12px;line-height:1.5">
|
|
||||||
Anyone who opens the app is automatically signed in as
|
|
||||||
<strong>${esc(defaultUser?.username || '(unknown)')}</strong> — no password required.
|
|
||||||
Only the admin login page still requires authentication.
|
|
||||||
</p>
|
|
||||||
`;
|
|
||||||
document.getElementById('revert-multi-btn').onclick = async () => {
|
|
||||||
await setAuthMode('multi', null);
|
|
||||||
loadAuthMode();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const userOptions = regularUsers.map(u =>
|
|
||||||
`<option value="${u.id}">${esc(u.username)}</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px">
|
|
||||||
<span style="background:var(--success-light);color:var(--success);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700">
|
|
||||||
Normal Login Active
|
|
||||||
</span>
|
|
||||||
<span class="muted">All users must sign in with their password.</span>
|
|
||||||
</div>
|
|
||||||
${regularUsers.length === 0
|
|
||||||
? `<p class="muted" style="font-size:12px">Create at least one user to enable single-user mode.</p>`
|
|
||||||
: `<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Auto-login as</label>
|
|
||||||
<select id="single-user-select" style="min-width:150px">
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="enable-single-btn">Enable Single-User Mode</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="margin-top:8px;font-size:12px;line-height:1.5">
|
|
||||||
Single-user mode removes the login screen. Anyone who opens the app gets
|
|
||||||
access as the selected user. The admin login at <code>/admin.html</code>
|
|
||||||
still requires a password.
|
|
||||||
</p>`
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (regularUsers.length > 0) {
|
|
||||||
document.getElementById('enable-single-btn').onclick = async () => {
|
|
||||||
const userId = document.getElementById('single-user-select').value;
|
|
||||||
if (!confirm('Enable single-user mode? Anyone who opens the app will be signed in automatically.')) return;
|
|
||||||
await setAuthMode('single', userId);
|
|
||||||
loadAuthMode();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAuthMode(mode, userId) {
|
|
||||||
const res = await fetch('/api/admin/auth-mode', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ auth_mode: mode, default_user_id: userId }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { panelStatus('Error: ' + data.error, 'err'); return; }
|
|
||||||
panelStatus(mode === 'single' ? 'Single-user mode enabled.' : 'Login restored.', 'ok');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
const res = await fetch('/api/admin/users');
|
|
||||||
if (!res.ok) return;
|
|
||||||
const users = await res.json();
|
|
||||||
|
|
||||||
const tbody = users.map(u => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(u.username)}</td>
|
|
||||||
<td><span class="role-badge role-${u.role}">${u.role}</span></td>
|
|
||||||
<td>${u.must_change_password
|
|
||||||
? '<span style="color:var(--warning)">Must change</span>'
|
|
||||||
: '<span class="muted">OK</span>'}</td>
|
|
||||||
<td>
|
|
||||||
${u.role !== 'admin' ? `
|
|
||||||
<form class="reset-form" onsubmit="resetPassword(event,${u.id})">
|
|
||||||
<input type="password" placeholder="New password" minlength="8" required>
|
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Reset</button>
|
|
||||||
<button type="button" class="btn btn-danger btn-sm"
|
|
||||||
onclick="deleteUser(${u.id},'${esc(u.username)}')">Delete</button>
|
|
||||||
</form>
|
|
||||||
` : '<span class="muted">—</span>'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
document.getElementById('users-table').innerHTML = `
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Username</th><th>Role</th><th>Password</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>${tbody || '<tr><td colspan="4" class="muted">No users yet.</td></tr>'}</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPassword(e, userId) {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.target.querySelector('input[type="password"]');
|
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/users/${userId}/password`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password: input.value }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
input.value = '';
|
|
||||||
panelStatus('Password reset. User will be prompted to change it on next login.', 'ok');
|
|
||||||
loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
panelStatus('Error: ' + err.message, 'err');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteUser(userId, username) {
|
|
||||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
panelStatus(`User "${username}" deleted.`, 'ok');
|
|
||||||
loadAuthMode();
|
|
||||||
loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
panelStatus('Error: ' + err.message, 'err');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('add-user-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
document.getElementById('add-status').textContent = '';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: document.getElementById('new-username').value,
|
|
||||||
password: document.getElementById('new-password').value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
document.getElementById('new-username').value = '';
|
|
||||||
document.getElementById('new-password').value = '';
|
|
||||||
document.getElementById('add-status').innerHTML = `<span class="ok">User "${esc(data.username)}" created.</span>`;
|
|
||||||
loadAuthMode();
|
|
||||||
loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('add-status').innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let panelTimer;
|
|
||||||
function panelStatus(msg, cls) {
|
|
||||||
const el = document.getElementById('panel-status') || (() => {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.id = 'panel-status';
|
|
||||||
document.getElementById('users-table').before(d);
|
|
||||||
return d;
|
|
||||||
})();
|
|
||||||
el.innerHTML = `<span class="${cls}">${esc(msg)}</span>`;
|
|
||||||
clearTimeout(panelTimer);
|
|
||||||
panelTimer = setTimeout(() => el.textContent = '', 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('logout-btn').onclick = async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
location.href = '/login.html';
|
|
||||||
};
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<')
|
|
||||||
.replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,888 +0,0 @@
|
||||||
/* ── Reset & base ── */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0d1526;
|
|
||||||
--surface: #162236;
|
|
||||||
--surface-2: #1c2d47;
|
|
||||||
--surface-3: #223558;
|
|
||||||
--border: rgba(255,255,255,0.07);
|
|
||||||
--border-strong: rgba(255,255,255,0.14);
|
|
||||||
--text: #e2e8f0;
|
|
||||||
--text-muted: #8faab8;
|
|
||||||
--text-faint: #506070;
|
|
||||||
--primary: #6366f1;
|
|
||||||
--primary-hover: #4f46e5;
|
|
||||||
--primary-light: rgba(99,102,241,0.18);
|
|
||||||
--success: #22d3a5;
|
|
||||||
--success-light: rgba(34,211,165,0.15);
|
|
||||||
--danger: #f43f5e;
|
|
||||||
--danger-light: rgba(244,63,94,0.15);
|
|
||||||
--warning: #fb923c;
|
|
||||||
--warning-light: rgba(251,146,60,0.15);
|
|
||||||
--info: #38bdf8;
|
|
||||||
--info-light: rgba(56,189,248,0.15);
|
|
||||||
--sidebar-w: 200px;
|
|
||||||
--header-h: 56px;
|
|
||||||
--radius: 6px;
|
|
||||||
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border);
|
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
|
|
||||||
|
|
||||||
/* ── Layout ── */
|
|
||||||
#app {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: var(--sidebar-w);
|
|
||||||
background: #0a1120;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; bottom: 0;
|
|
||||||
z-index: 100;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 20px 16px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 30px; height: 30px;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-weight: 800; font-size: 15px; color: white;
|
|
||||||
box-shadow: 0 0 12px rgba(99,102,241,0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links li { margin: 2px 0; }
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13.5px;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
.nav-link.active { background: var(--primary-light); color: var(--primary); }
|
|
||||||
|
|
||||||
.nav-icon { font-size: 12px; opacity: .8; }
|
|
||||||
|
|
||||||
.sidebar-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 8px 8px 12px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar-username {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: 4px 10px 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logout {
|
|
||||||
font-size: 12px !important;
|
|
||||||
opacity: .7;
|
|
||||||
}
|
|
||||||
.sidebar-logout:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: var(--sidebar-w);
|
|
||||||
flex: 1;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page { display: none; }
|
|
||||||
.page.active { display: block; }
|
|
||||||
|
|
||||||
/* ── Page header ── */
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Summary cards ── */
|
|
||||||
.summary-bar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 16px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0; left: 0; right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.danger::before { background: var(--danger); }
|
|
||||||
.summary-card.success::before { background: var(--success); }
|
|
||||||
.summary-card.warning::before { background: var(--warning); }
|
|
||||||
|
|
||||||
.summary-card .label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card .value {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.danger .value { color: var(--danger); }
|
|
||||||
.summary-card.success .value { color: var(--success); }
|
|
||||||
.summary-card.warning .value { color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Month nav ── */
|
|
||||||
.month-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-nav .month-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 130px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tracker table ── */
|
|
||||||
.bucket-section {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-bottom: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .07em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-totals {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table th {
|
|
||||||
background: var(--surface-2);
|
|
||||||
padding: 9px 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 1px solid var(--border-strong);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table td {
|
|
||||||
padding: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.tracker-table tr:hover td { background: var(--surface-2); }
|
|
||||||
.tracker-table tr.row-paid:hover td { background: rgba(34,211,165,0.07); }
|
|
||||||
.tracker-table tr.row-late:hover td { background: rgba(251,146,60,0.07); }
|
|
||||||
.tracker-table tr.row-missed:hover td { background: rgba(244,63,94,0.07); }
|
|
||||||
|
|
||||||
.tracker-table tr.row-paid td { background: rgba(34,211,165,0.04); }
|
|
||||||
.tracker-table tr.row-late td { background: rgba(251,146,60,0.04); }
|
|
||||||
.tracker-table tr.row-missed td { background: rgba(244,63,94,0.04); }
|
|
||||||
.tracker-table tr.row-autodraft td { background: rgba(251,146,60,0.04); }
|
|
||||||
|
|
||||||
.td-inner {
|
|
||||||
padding: 10px 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status badge ── */
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-paid { background: var(--success-light); color: var(--success); }
|
|
||||||
.badge-upcoming { background: var(--surface-3); color: var(--text-muted); }
|
|
||||||
.badge-due-soon { background: var(--warning-light); color: var(--warning); }
|
|
||||||
.badge-late { background: var(--warning-light); color: var(--warning); }
|
|
||||||
.badge-missed { background: var(--danger-light); color: var(--danger); }
|
|
||||||
.badge-autodraft { background: var(--warning-light); color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Inline editable cells ── */
|
|
||||||
.editable-cell {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
min-width: 80px;
|
|
||||||
transition: background .1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-cell:hover { background: var(--primary-light); }
|
|
||||||
.editable-cell.empty { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.editable-cell input {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
color: var(--text);
|
|
||||||
width: 100%;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bill name cell ── */
|
|
||||||
.bill-name-cell {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.bill-category {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Quick pay group ── */
|
|
||||||
.quick-pay-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-pay-group input[type="number"] {
|
|
||||||
width: 80px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Action buttons ── */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s, opacity .15s, box-shadow .15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-primary { background: var(--primary); color: white; }
|
|
||||||
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
|
|
||||||
|
|
||||||
.btn-success { background: var(--success); color: #0d1526; }
|
|
||||||
.btn-success:hover:not(:disabled) { background: #1ab890; }
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
}
|
|
||||||
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-danger:hover:not(:disabled) { background: #e11d48; }
|
|
||||||
|
|
||||||
.btn-sm { padding: 4px 8px; font-size: 12px; }
|
|
||||||
|
|
||||||
.btn-pay {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary);
|
|
||||||
border: 1px solid rgba(99,102,241,0.3);
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
.btn-pay:hover { background: var(--primary); color: white; border-color: var(--primary); }
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-faint);
|
|
||||||
border: none;
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
|
|
||||||
/* ── Action cell ── */
|
|
||||||
.action-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Amount display ── */
|
|
||||||
.amount-expected { color: var(--text-muted); font-size: 13px; }
|
|
||||||
.amount-actual { font-weight: 600; color: var(--text); }
|
|
||||||
.amount-mismatch { color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Bills management page ── */
|
|
||||||
.bills-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bill-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
transition: border-color .15s, background .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bill-card:hover { border-color: var(--primary); background: var(--surface-2); }
|
|
||||||
.bill-card.inactive { opacity: .45; }
|
|
||||||
|
|
||||||
.bill-card-info { flex: 1; min-width: 0; }
|
|
||||||
.bill-card-name { font-weight: 600; font-size: 14px; color: var(--text); }
|
|
||||||
.bill-card-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
|
||||||
|
|
||||||
.bill-card-amount {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
min-width: 80px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bill-card-actions { display: flex; gap: 6px; }
|
|
||||||
|
|
||||||
/* ── Categories page ── */
|
|
||||||
.cat-list { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
|
|
||||||
.cat-item {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 10px 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
transition: border-color .15s;
|
|
||||||
}
|
|
||||||
.cat-item:hover { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.cat-name { flex: 1; font-weight: 500; color: var(--text); }
|
|
||||||
|
|
||||||
.cat-add-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Settings page ── */
|
|
||||||
.settings-section {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row:last-child { margin-bottom: 0; }
|
|
||||||
|
|
||||||
.settings-row label {
|
|
||||||
min-width: 180px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Forms ── */
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group.full-width { grid-column: 1 / -1; }
|
|
||||||
.form-group.checkbox-group { flex-direction: row; flex-wrap: wrap; gap: 16px; }
|
|
||||||
.form-group.checkbox-group label {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 500; color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="date"],
|
|
||||||
input[type="email"],
|
|
||||||
input[type="password"],
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-2);
|
|
||||||
transition: border-color .15s, box-shadow .15s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
select option {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder { color: var(--text-faint); }
|
|
||||||
textarea::placeholder { color: var(--text-faint); }
|
|
||||||
textarea { resize: vertical; }
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
accent-color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ── */
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.hidden { display: none; }
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.65);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-box {
|
|
||||||
position: relative;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 10px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-box-sm { max-width: 380px; }
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 22px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color .15s;
|
|
||||||
}
|
|
||||||
.modal-close:hover { color: var(--text); }
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toast notifications ── */
|
|
||||||
#toast-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
gap: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 280px;
|
|
||||||
max-width: 380px;
|
|
||||||
padding: 12px 14px 12px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-left-width: 3px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px) translateY(4px);
|
|
||||||
transition: opacity .25s ease, transform .25s ease;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.hide {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px) translateY(4px);
|
|
||||||
transition: opacity .2s ease, transform .2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-icon {
|
|
||||||
font-size: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-body { flex: 1; line-height: 1.4; }
|
|
||||||
|
|
||||||
.toast.success { border-left-color: var(--success); }
|
|
||||||
.toast.success .toast-icon { color: var(--success); }
|
|
||||||
|
|
||||||
.toast.error { border-left-color: var(--danger); }
|
|
||||||
.toast.error .toast-icon { color: var(--danger); }
|
|
||||||
|
|
||||||
.toast.warning { border-left-color: var(--warning); }
|
|
||||||
.toast.warning .toast-icon { color: var(--warning); }
|
|
||||||
|
|
||||||
.toast.info { border-left-color: var(--info); }
|
|
||||||
.toast.info .toast-icon { color: var(--info); }
|
|
||||||
|
|
||||||
/* Legacy single #toast element (backwards compatibility) */
|
|
||||||
#toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 11px 16px 11px 18px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-left: 3px solid var(--primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
||||||
z-index: 999;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
transition: opacity .25s, transform .25s;
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toast.show { opacity: 1; transform: translateX(0); }
|
|
||||||
#toast.success { border-left-color: var(--success); }
|
|
||||||
#toast.error { border-left-color: var(--danger); }
|
|
||||||
#toast.warning { border-left-color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Status page ── */
|
|
||||||
.status-page {
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px 18px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.green {
|
|
||||||
background: var(--success);
|
|
||||||
box-shadow: 0 0 0 0 rgba(34,211,165,0.4);
|
|
||||||
animation: pulse-green 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.red {
|
|
||||||
background: var(--danger);
|
|
||||||
box-shadow: 0 0 0 0 rgba(244,63,94,0.4);
|
|
||||||
animation: pulse-red 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-green {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(34,211,165,0.5); }
|
|
||||||
70% { box-shadow: 0 0 0 7px rgba(34,211,165,0); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(34,211,165,0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-red {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(244,63,94,0.5); }
|
|
||||||
70% { box-shadow: 0 0 0 7px rgba(244,63,94,0); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(244,63,94,0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 9px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.stat-row .stat-label { color: var(--text-muted); }
|
|
||||||
.stat-row .stat-value { font-weight: 600; color: var(--text); }
|
|
||||||
|
|
||||||
/* ── Misc ── */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px 20px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.empty-state p { font-size: 14px; margin-top: 8px; }
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 32px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autopay-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px; height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--warning);
|
|
||||||
margin-right: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted { color: var(--text-muted); }
|
|
||||||
.text-faint { color: var(--text-faint); }
|
|
||||||
.text-sm { font-size: 12px; }
|
|
||||||
.mt-1 { margin-top: 4px; }
|
|
||||||
.gap-8 { gap: 8px; }
|
|
||||||
|
|
||||||
/* ── Divider ── */
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Code / monospace ── */
|
|
||||||
code {
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--info);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.sidebar { width: 60px; }
|
|
||||||
.logo-text, .nav-link span:not(.nav-icon) { display: none; }
|
|
||||||
.main-content { margin-left: 60px; padding: 16px; }
|
|
||||||
.summary-bar { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bill Tracker</title>
|
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<nav class="sidebar">
|
|
||||||
<div class="logo">
|
|
||||||
<span class="logo-icon">$</span>
|
|
||||||
<span class="logo-text">BillTracker</span>
|
|
||||||
</div>
|
|
||||||
<ul class="nav-links">
|
|
||||||
<li><a href="#tracker" class="nav-link active" data-page="tracker">
|
|
||||||
<span class="nav-icon">☰</span> Tracker
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#bills" class="nav-link" data-page="bills">
|
|
||||||
<span class="nav-icon">●</span> Bills
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#categories" class="nav-link" data-page="categories">
|
|
||||||
<span class="nav-icon">◆</span> Categories
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#settings" class="nav-link" data-page="settings">
|
|
||||||
<span class="nav-icon">⚙</span> Settings
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#status" class="nav-link" data-page="status">
|
|
||||||
<span class="nav-icon">⏺</span> Status
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<span id="sidebar-username"></span>
|
|
||||||
<a href="#" id="sidebar-logout" class="nav-link sidebar-logout">
|
|
||||||
<span class="nav-icon">←</span> Sign Out
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<div id="page-tracker" class="page active"></div>
|
|
||||||
<div id="page-bills" class="page"></div>
|
|
||||||
<div id="page-categories" class="page"></div>
|
|
||||||
<div id="page-settings" class="page"></div>
|
|
||||||
<div id="page-status" class="page"></div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bill Modal -->
|
|
||||||
<div id="bill-modal" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="bill-modal-title">Add Bill</h2>
|
|
||||||
<button class="modal-close" id="bill-modal-close">×</button>
|
|
||||||
</div>
|
|
||||||
<form id="bill-form" class="modal-form">
|
|
||||||
<input type="hidden" id="bill-id">
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-name">Name *</label>
|
|
||||||
<input type="text" id="bill-name" placeholder="e.g. Electricity" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-category">Category</label>
|
|
||||||
<select id="bill-category">
|
|
||||||
<option value="">— none —</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-due-day">Due Day (1–31) *</label>
|
|
||||||
<input type="number" id="bill-due-day" min="1" max="31" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-expected">Expected Amount ($)</label>
|
|
||||||
<input type="number" id="bill-expected" min="0" step="0.01" placeholder="0.00">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-interest-rate">Interest rate (APR %)</label>
|
|
||||||
<input type="number" id="bill-interest-rate" min="0" max="100" step="0.01" placeholder="Optional">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-cycle">Billing Cycle</label>
|
|
||||||
<select id="bill-cycle">
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="quarterly">Quarterly</option>
|
|
||||||
<option value="annually">Annually</option>
|
|
||||||
<option value="irregular">Irregular</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="bill-autopay"> Autopay / Autodraft
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="bill-2fa"> Has 2FA
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-website">Website</label>
|
|
||||||
<input type="text" id="bill-website" placeholder="https://...">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-username">Username / Email</label>
|
|
||||||
<input type="text" id="bill-username">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-account-info">Account Info</label>
|
|
||||||
<input type="text" id="bill-account-info" placeholder="Last 4 digits, account #...">
|
|
||||||
</div>
|
|
||||||
<div class="form-group full-width">
|
|
||||||
<label for="bill-notes">Notes</label>
|
|
||||||
<textarea id="bill-notes" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="bill-modal-cancel" class="btn btn-ghost">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save Bill</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Modal -->
|
|
||||||
<div id="payment-modal" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box modal-box-sm">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="payment-modal-title">Edit Payment</h2>
|
|
||||||
<button class="modal-close" id="payment-modal-close">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="payment-form" class="modal-form">
|
|
||||||
<input type="hidden" id="payment-bill-id">
|
|
||||||
<input type="hidden" id="payment-id">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-amount">Amount ($) *</label>
|
|
||||||
<input type="number" id="payment-amount" min="0" step="0.01" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-date">Paid Date *</label>
|
|
||||||
<input type="date" id="payment-date" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-method">Method</label>
|
|
||||||
<select id="payment-method">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option value="bank">Bank Transfer</option>
|
|
||||||
<option value="card">Card</option>
|
|
||||||
<option value="autopay">Autopay</option>
|
|
||||||
<option value="check">Check</option>
|
|
||||||
<option value="cash">Cash</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-notes">Notes</label>
|
|
||||||
<input type="text" id="payment-notes">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- UPDATED FOOTER -->
|
|
||||||
<div class="modal-footer">
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="payment-delete"
|
|
||||||
class="btn btn-danger"
|
|
||||||
style="margin-right:auto"
|
|
||||||
title="Removes this payment record and marks the bill as unpaid. The bill itself is NOT deleted.">
|
|
||||||
Remove Payment
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" id="payment-modal-cancel" class="btn btn-ghost">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Privacy notice — shown on first login -->
|
|
||||||
<div id="privacy-overlay" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box modal-box-sm" style="text-align:left">
|
|
||||||
<div style="font-size:22px;margin-bottom:12px">🔒</div>
|
|
||||||
<h2 style="font-size:17px;margin-bottom:12px">Your data is private</h2>
|
|
||||||
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:14px">
|
|
||||||
The <strong>admin account</strong> on this system can only:
|
|
||||||
</p>
|
|
||||||
<ul style="margin:0 0 16px 20px;color:var(--text-muted);font-size:14px;line-height:1.9">
|
|
||||||
<li>Create new user accounts</li>
|
|
||||||
<li>Reset your password if you're locked out</li>
|
|
||||||
</ul>
|
|
||||||
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:20px">
|
|
||||||
The admin <strong>cannot</strong> view, edit, or access your bills,
|
|
||||||
payments, or any financial data — by design.
|
|
||||||
</p>
|
|
||||||
<button class="btn btn-primary" id="privacy-ack-btn" style="width:100%">Got it, take me to my tracker</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Password change — shown when must_change_password is set -->
|
|
||||||
<div id="change-password-overlay" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box modal-box-sm">
|
|
||||||
<h2 style="margin-bottom:6px">Change Your Password</h2>
|
|
||||||
<p style="color:var(--text-muted);font-size:13px;margin-bottom:18px">
|
|
||||||
Your password was reset by the admin. Please set a new one to continue.
|
|
||||||
</p>
|
|
||||||
<form id="change-password-form">
|
|
||||||
<div class="form-group" style="margin-bottom:12px">
|
|
||||||
<label for="cp-new">New Password</label>
|
|
||||||
<input type="password" id="cp-new" minlength="8" required placeholder="At least 8 characters">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom:16px">
|
|
||||||
<label for="cp-confirm">Confirm Password</label>
|
|
||||||
<input type="password" id="cp-confirm" minlength="8" required>
|
|
||||||
</div>
|
|
||||||
<div id="cp-error" style="color:var(--danger);font-size:13px;margin-bottom:10px;min-height:18px"></div>
|
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%">Set New Password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
|
||||||
<script src="/js/tracker.js"></script>
|
|
||||||
<script src="/js/bills.js"></script>
|
|
||||||
<script src="/js/categories.js"></script>
|
|
||||||
<script src="/js/settings.js"></script>
|
|
||||||
<script src="/js/status.js"></script>
|
|
||||||
<script src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/* Thin API client — all fetch calls go through here */
|
|
||||||
|
|
||||||
const API = {
|
|
||||||
async _fetch(method, path, body) {
|
|
||||||
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
|
|
||||||
// Add CSRF token header for state-changing methods
|
|
||||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
||||||
const name = 'bt_csrf_token';
|
|
||||||
const match = document.cookie.match(new RegExp(name + '=([^;]+)'));
|
|
||||||
if (match) opts.headers['x-csrf-token'] = match[1];
|
|
||||||
}
|
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
||||||
const res = await fetch('/api' + path, opts);
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
get: (path) => API._fetch('GET', path),
|
|
||||||
post: (path, body) => API._fetch('POST', path, body),
|
|
||||||
put: (path, body) => API._fetch('PUT', path, body),
|
|
||||||
delete: (path) => API._fetch('DELETE', path),
|
|
||||||
|
|
||||||
// Tracker
|
|
||||||
tracker: (year, month) => API.get(`/tracker?year=${year}&month=${month}`),
|
|
||||||
|
|
||||||
// Bills
|
|
||||||
bills: () => API.get('/bills'),
|
|
||||||
allBills: () => API.get('/bills?inactive=true'),
|
|
||||||
bill: (id) => API.get(`/bills/${id}`),
|
|
||||||
createBill: (data) => API.post('/bills', data),
|
|
||||||
updateBill: (id, d) => API.put(`/bills/${id}`, d),
|
|
||||||
deleteBill: (id) => API.delete(`/bills/${id}`),
|
|
||||||
|
|
||||||
// Payments
|
|
||||||
payments: (billId, y, m) => API.get(`/payments?bill_id=${billId}&year=${y}&month=${m}`),
|
|
||||||
quickPay: (data) => API.post('/payments/quick', data),
|
|
||||||
createPayment: (data) => API.post('/payments', data),
|
|
||||||
updatePayment: (id, data) => API.put(`/payments/${id}`, data),
|
|
||||||
deletePayment: (id) => API.delete(`/payments/${id}`),
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
categories: () => API.get('/categories'),
|
|
||||||
createCategory: (name) => API.post('/categories', { name }),
|
|
||||||
updateCategory: (id, n) => API.put(`/categories/${id}`, { name: n }),
|
|
||||||
deleteCategory: (id) => API.delete(`/categories/${id}`),
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
settings: () => API.get('/settings'),
|
|
||||||
saveSettings: (data) => API.put('/settings', data),
|
|
||||||
};
|
|
||||||
179
legacy/js/app.js
179
legacy/js/app.js
|
|
@ -1,179 +0,0 @@
|
||||||
/* ── App bootstrap & routing ── */
|
|
||||||
|
|
||||||
const PAGES = {
|
|
||||||
tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
|
|
||||||
bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
|
|
||||||
categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
|
|
||||||
settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
|
|
||||||
status: { container: 'page-status', init: (el) => StatusPage.init(el) },
|
|
||||||
};
|
|
||||||
|
|
||||||
let activePage = null;
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function boot() {
|
|
||||||
let res;
|
|
||||||
try { res = await fetch('/api/auth/me'); }
|
|
||||||
catch { location.href = '/login.html'; return; }
|
|
||||||
|
|
||||||
if (!res.ok) { location.href = '/login.html'; return; }
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
currentUser = data.user;
|
|
||||||
|
|
||||||
if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
|
|
||||||
|
|
||||||
document.getElementById('sidebar-username').textContent = currentUser.username;
|
|
||||||
|
|
||||||
if (data.single_user_mode) {
|
|
||||||
document.getElementById('sidebar-logout').style.display = 'none';
|
|
||||||
startApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
|
|
||||||
if (currentUser.first_login) { showPrivacyNotice(); return; }
|
|
||||||
|
|
||||||
startApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Password change overlay ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showChangePasswordOverlay() {
|
|
||||||
document.getElementById('change-password-overlay').classList.remove('hidden');
|
|
||||||
|
|
||||||
document.getElementById('change-password-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const np = document.getElementById('cp-new').value;
|
|
||||||
const cnf = document.getElementById('cp-confirm').value;
|
|
||||||
const err = document.getElementById('cp-error');
|
|
||||||
|
|
||||||
if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
|
|
||||||
if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
|
|
||||||
err.textContent = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/change-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ new_password: np }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
if (!res.ok) throw new Error(body.error);
|
|
||||||
document.getElementById('change-password-overlay').classList.add('hidden');
|
|
||||||
currentUser.must_change_password = false;
|
|
||||||
currentUser.first_login ? showPrivacyNotice() : startApp();
|
|
||||||
} catch (ex) {
|
|
||||||
document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Privacy notice overlay ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showPrivacyNotice() {
|
|
||||||
document.getElementById('privacy-overlay').classList.remove('hidden');
|
|
||||||
document.getElementById('privacy-ack-btn').onclick = async () => {
|
|
||||||
await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
|
|
||||||
document.getElementById('privacy-overlay').classList.add('hidden');
|
|
||||||
currentUser.first_login = false;
|
|
||||||
startApp();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main app ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startApp() {
|
|
||||||
document.getElementById('app').style.visibility = 'visible';
|
|
||||||
setupLogout();
|
|
||||||
setupNavLinks();
|
|
||||||
handleHash();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupLogout() {
|
|
||||||
document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
location.href = '/login.html';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupNavLinks() {
|
|
||||||
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
|
|
||||||
link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
|
|
||||||
});
|
|
||||||
window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHash() {
|
|
||||||
navigate(location.hash.replace('#', '') || 'tracker');
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(page) {
|
|
||||||
if (!PAGES[page]) page = 'tracker';
|
|
||||||
|
|
||||||
document.querySelectorAll('.nav-link[data-page]').forEach(a => {
|
|
||||||
a.classList.toggle('active', a.dataset.page === page);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
|
|
||||||
document.getElementById(PAGES[page].container).classList.add('active');
|
|
||||||
|
|
||||||
if (activePage !== page) {
|
|
||||||
activePage = page;
|
|
||||||
PAGES[page].init(document.getElementById(PAGES[page].container));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast system ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TOAST_ICONS = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✕',
|
|
||||||
warning: '⚠',
|
|
||||||
info: 'ℹ',
|
|
||||||
};
|
|
||||||
|
|
||||||
let toastContainer = null;
|
|
||||||
|
|
||||||
function getToastContainer() {
|
|
||||||
if (!toastContainer) {
|
|
||||||
toastContainer = document.createElement('div');
|
|
||||||
toastContainer.id = 'toast-container';
|
|
||||||
document.body.appendChild(toastContainer);
|
|
||||||
}
|
|
||||||
return toastContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg, type = 'info', duration = 3500) {
|
|
||||||
const container = getToastContainer();
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
|
|
||||||
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.innerHTML = `
|
|
||||||
<span class="toast-icon">${icon}</span>
|
|
||||||
<span class="toast-msg">${msg}</span>
|
|
||||||
<button class="toast-close" aria-label="Dismiss">×</button>
|
|
||||||
<div class="toast-bar"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.appendChild(toast);
|
|
||||||
// Trigger animation
|
|
||||||
requestAnimationFrame(() => toast.classList.add('toast-show'));
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
toast.classList.remove('toast-show');
|
|
||||||
toast.classList.add('toast-hide');
|
|
||||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const timer = setTimeout(dismiss, duration);
|
|
||||||
toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide app shell until auth check completes (prevents flash)
|
|
||||||
document.getElementById('app').style.visibility = 'hidden';
|
|
||||||
|
|
||||||
boot();
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
/* ── Bills management page ── */
|
|
||||||
|
|
||||||
const BillsPage = (() => {
|
|
||||||
let categories = [];
|
|
||||||
|
|
||||||
const CYCLE_LABELS = {
|
|
||||||
monthly: 'Monthly', quarterly: 'Quarterly',
|
|
||||||
annually: 'Annually', irregular: 'Irregular',
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmt(amount) {
|
|
||||||
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str || '')
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init(container) {
|
|
||||||
container.innerHTML = `<div class="loading">Loading...</div>`;
|
|
||||||
try {
|
|
||||||
[categories] = await Promise.all([API.categories()]);
|
|
||||||
render(container);
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(container) {
|
|
||||||
const bills = await API.allBills();
|
|
||||||
const active = bills.filter(b => b.active);
|
|
||||||
const inactive = bills.filter(b => !b.active);
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Bills</h1>
|
|
||||||
<button class="btn btn-primary" id="add-bill-btn">+ Add Bill</button>
|
|
||||||
</div>
|
|
||||||
<div class="bills-grid" id="bills-list">
|
|
||||||
${active.map(b => renderCard(b)).join('')}
|
|
||||||
${inactive.length ? `
|
|
||||||
<div style="margin-top:24px">
|
|
||||||
<div class="bucket-label" style="margin-bottom:12px">INACTIVE</div>
|
|
||||||
${inactive.map(b => renderCard(b, true)).join('')}
|
|
||||||
</div>` : ''}
|
|
||||||
${bills.length === 0 ? `<div class="empty-state"><p>No bills yet. Add your first bill!</p></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('add-bill-btn').onclick = () => openBillModal(null, () => render(container));
|
|
||||||
|
|
||||||
container.querySelectorAll('.btn-edit-bill').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
const bill = await API.bill(btn.dataset.id);
|
|
||||||
openBillModal(bill, () => render(container));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.btn-toggle-bill').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
const active = btn.dataset.active === '1';
|
|
||||||
if (!active || confirm('Deactivate this bill? It will be hidden from the tracker.')) {
|
|
||||||
await API.updateBill(btn.dataset.id, { active: active ? 0 : 1 });
|
|
||||||
render(container);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCard(bill, inactive = false) {
|
|
||||||
const catName = categories.find(c => c.id === bill.category_id)?.name || '';
|
|
||||||
return `
|
|
||||||
<div class="bill-card ${inactive ? 'inactive' : ''}">
|
|
||||||
<div class="bill-card-info">
|
|
||||||
<div class="bill-card-name">${escHtml(bill.name)}</div>
|
|
||||||
<div class="bill-card-meta">
|
|
||||||
Day ${bill.due_day}
|
|
||||||
${catName ? ` · ${escHtml(catName)}` : ''}
|
|
||||||
· ${CYCLE_LABELS[bill.billing_cycle] || bill.billing_cycle}
|
|
||||||
${bill.autopay_enabled ? ' · <span style="color:var(--warning)">Autopay</span>' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bill-card-amount">${fmt(bill.expected_amount)}</div>
|
|
||||||
<div class="bill-card-actions">
|
|
||||||
<button class="btn btn-ghost btn-sm btn-edit-bill" data-id="${bill.id}">Edit</button>
|
|
||||||
<button class="btn btn-ghost btn-sm btn-toggle-bill"
|
|
||||||
data-id="${bill.id}" data-active="${bill.active}">
|
|
||||||
${bill.active ? 'Deactivate' : 'Activate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBillModal(bill, onSave) {
|
|
||||||
const modal = document.getElementById('bill-modal');
|
|
||||||
const isNew = !bill;
|
|
||||||
|
|
||||||
document.getElementById('bill-modal-title').textContent = isNew ? 'Add Bill' : 'Edit Bill';
|
|
||||||
document.getElementById('bill-id').value = bill?.id || '';
|
|
||||||
document.getElementById('bill-name').value = bill?.name || '';
|
|
||||||
document.getElementById('bill-due-day').value = bill?.due_day || '';
|
|
||||||
document.getElementById('bill-expected').value = bill?.expected_amount || '';
|
|
||||||
document.getElementById('bill-interest-rate').value = bill?.interest_rate ?? '';
|
|
||||||
document.getElementById('bill-cycle').value = bill?.billing_cycle || 'monthly';
|
|
||||||
document.getElementById('bill-autopay').checked = !!bill?.autopay_enabled;
|
|
||||||
document.getElementById('bill-2fa').checked = !!bill?.has_2fa;
|
|
||||||
document.getElementById('bill-website').value = bill?.website || '';
|
|
||||||
document.getElementById('bill-username').value = bill?.username || '';
|
|
||||||
document.getElementById('bill-account-info').value = bill?.account_info || '';
|
|
||||||
document.getElementById('bill-notes').value = bill?.notes || '';
|
|
||||||
|
|
||||||
// Populate category select
|
|
||||||
const catSelect = document.getElementById('bill-category');
|
|
||||||
catSelect.innerHTML = '<option value="">— none —</option>' +
|
|
||||||
categories.map(c => `<option value="${c.id}" ${bill?.category_id == c.id ? 'selected' : ''}>${escHtml(c.name)}</option>`).join('');
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
const close = () => modal.classList.add('hidden');
|
|
||||||
document.getElementById('bill-modal-close').onclick = close;
|
|
||||||
document.getElementById('bill-modal-cancel').onclick = close;
|
|
||||||
modal.querySelector('.modal-overlay').onclick = close;
|
|
||||||
|
|
||||||
document.getElementById('bill-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
name: document.getElementById('bill-name').value.trim(),
|
|
||||||
category_id: document.getElementById('bill-category').value || null,
|
|
||||||
due_day: parseInt(document.getElementById('bill-due-day').value, 10),
|
|
||||||
expected_amount: parseFloat(document.getElementById('bill-expected').value) || 0,
|
|
||||||
interest_rate: document.getElementById('bill-interest-rate').value === '' ? null : parseFloat(document.getElementById('bill-interest-rate').value),
|
|
||||||
billing_cycle: document.getElementById('bill-cycle').value,
|
|
||||||
autopay_enabled: document.getElementById('bill-autopay').checked,
|
|
||||||
has_2fa: document.getElementById('bill-2fa').checked,
|
|
||||||
website: document.getElementById('bill-website').value || null,
|
|
||||||
username: document.getElementById('bill-username').value || null,
|
|
||||||
account_info: document.getElementById('bill-account-info').value || null,
|
|
||||||
notes: document.getElementById('bill-notes').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isNew) {
|
|
||||||
await API.createBill(data);
|
|
||||||
showToast('Bill added', 'success');
|
|
||||||
} else {
|
|
||||||
await API.updateBill(bill.id, data);
|
|
||||||
showToast('Bill updated', 'success');
|
|
||||||
}
|
|
||||||
close();
|
|
||||||
onSave();
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
/* ── Categories page ── */
|
|
||||||
|
|
||||||
const CategoriesPage = (() => {
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init(container) {
|
|
||||||
await render(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(container) {
|
|
||||||
let cats;
|
|
||||||
try {
|
|
||||||
cats = await API.categories();
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Categories</h1>
|
|
||||||
</div>
|
|
||||||
<form class="cat-add-form" id="cat-add-form">
|
|
||||||
<input type="text" id="cat-new-name" placeholder="New category name" style="max-width:280px">
|
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
|
||||||
</form>
|
|
||||||
<div class="cat-list" id="cat-list">
|
|
||||||
${cats.map(c => renderItem(c)).join('')}
|
|
||||||
${cats.length === 0 ? `<div class="empty-state"><p>No categories yet.</p></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('cat-add-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = document.getElementById('cat-new-name').value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
try {
|
|
||||||
await API.createCategory(name);
|
|
||||||
document.getElementById('cat-new-name').value = '';
|
|
||||||
showToast('Category added', 'success');
|
|
||||||
render(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.querySelectorAll('.btn-delete-cat').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
if (!confirm('Delete this category? Bills using it will be uncategorized.')) return;
|
|
||||||
try {
|
|
||||||
await API.deleteCategory(btn.dataset.id);
|
|
||||||
showToast('Category deleted', 'success');
|
|
||||||
render(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.cat-name-span').forEach(span => {
|
|
||||||
span.ondblclick = () => startRename(span, cats.find(c => c.id == span.dataset.id), container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderItem(cat) {
|
|
||||||
return `
|
|
||||||
<div class="cat-item" data-cat-id="${cat.id}">
|
|
||||||
<span class="cat-name cat-name-span" data-id="${cat.id}" title="Double-click to rename">
|
|
||||||
${escHtml(cat.name)}
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-ghost btn-sm btn-delete-cat" data-id="${cat.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRename(span, cat, container) {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.value = cat.name;
|
|
||||||
input.className = 'cat-name';
|
|
||||||
input.style.flex = '1';
|
|
||||||
span.replaceWith(input);
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
|
|
||||||
async function commit() {
|
|
||||||
const name = input.value.trim();
|
|
||||||
if (!name || name === cat.name) { render(container); return; }
|
|
||||||
try {
|
|
||||||
await API.updateCategory(cat.id, name);
|
|
||||||
showToast('Renamed', 'success');
|
|
||||||
render(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
render(container);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('blur', commit);
|
|
||||||
input.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') input.blur();
|
|
||||||
if (e.key === 'Escape') render(container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
/* ── Settings page ── */
|
|
||||||
|
|
||||||
const SettingsPage = (() => {
|
|
||||||
async function init(container) {
|
|
||||||
let settings, notifPrefs;
|
|
||||||
try {
|
|
||||||
[settings, notifPrefs] = await Promise.all([
|
|
||||||
API.settings(),
|
|
||||||
fetch('/api/notifications/me').then(r => r.ok ? r.json() : null),
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = `<div class="empty-state"><p>Failed to load settings.</p></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifSection = buildNotifSection(notifPrefs);
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Settings</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>General</h3>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-currency">Currency</label>
|
|
||||||
<select id="s-currency" style="max-width:120px">
|
|
||||||
<option value="USD" ${settings.currency === 'USD' ? 'selected' : ''}>USD $</option>
|
|
||||||
<option value="EUR" ${settings.currency === 'EUR' ? 'selected' : ''}>EUR €</option>
|
|
||||||
<option value="GBP" ${settings.currency === 'GBP' ? 'selected' : ''}>GBP £</option>
|
|
||||||
<option value="CAD" ${settings.currency === 'CAD' ? 'selected' : ''}>CAD $</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-date-format">Date Format</label>
|
|
||||||
<select id="s-date-format" style="max-width:160px">
|
|
||||||
<option value="MM/DD/YYYY" ${settings.date_format === 'MM/DD/YYYY' ? 'selected' : ''}>MM/DD/YYYY</option>
|
|
||||||
<option value="DD/MM/YYYY" ${settings.date_format === 'DD/MM/YYYY' ? 'selected' : ''}>DD/MM/YYYY</option>
|
|
||||||
<option value="YYYY-MM-DD" ${settings.date_format === 'YYYY-MM-DD' ? 'selected' : ''}>YYYY-MM-DD</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Billing Behavior</h3>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-grace">Grace Period (days)</label>
|
|
||||||
<input type="number" id="s-grace" min="0" max="30" value="${settings.grace_period_days || 5}" style="max-width:80px">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Backup</h3>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label>Auto Backup</label>
|
|
||||||
<label style="cursor:pointer">
|
|
||||||
<input type="checkbox" id="s-backup-enabled" ${settings.backup_enabled === 'true' ? 'checked' : ''}>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-backup-freq">Frequency (days)</label>
|
|
||||||
<input type="number" id="s-backup-freq" min="1" max="30" value="${settings.backup_frequency_days || 1}" style="max-width:80px">
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-backup-keep">Keep N Backups</label>
|
|
||||||
<input type="number" id="s-backup-keep" min="1" max="90" value="${settings.backup_keep_count || 14}" style="max-width:80px">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${notifSection}
|
|
||||||
|
|
||||||
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:8px">
|
|
||||||
<button class="btn btn-primary" id="save-settings-btn">Save Settings</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('save-settings-btn').onclick = async () => {
|
|
||||||
const data = {
|
|
||||||
currency: document.getElementById('s-currency').value,
|
|
||||||
date_format: document.getElementById('s-date-format').value,
|
|
||||||
grace_period_days: document.getElementById('s-grace').value,
|
|
||||||
backup_enabled: document.getElementById('s-backup-enabled').checked ? 'true' : 'false',
|
|
||||||
backup_frequency_days: document.getElementById('s-backup-freq').value,
|
|
||||||
backup_keep_count: document.getElementById('s-backup-keep').value,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await API.saveSettings(data);
|
|
||||||
showToast('Settings saved', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Notification save (only wired if section exists)
|
|
||||||
const notifForm = document.getElementById('notif-user-form');
|
|
||||||
if (notifForm) {
|
|
||||||
notifForm.onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = notifForm.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/me', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
notification_email: document.getElementById('n-email').value.trim(),
|
|
||||||
notifications_enabled:document.getElementById('n-enabled').checked,
|
|
||||||
notify_3d: document.getElementById('n-3d').checked,
|
|
||||||
notify_1d: document.getElementById('n-1d').checked,
|
|
||||||
notify_due: document.getElementById('n-due').checked,
|
|
||||||
notify_overdue: document.getElementById('n-overdue').checked,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
|
||||||
showToast('Notification preferences saved', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle field visibility based on enabled checkbox
|
|
||||||
const toggle = () => {
|
|
||||||
const on = document.getElementById('n-enabled').checked;
|
|
||||||
document.getElementById('n-options').style.opacity = on ? '1' : '.4';
|
|
||||||
document.getElementById('n-options').style.pointerEvents = on ? '' : 'none';
|
|
||||||
};
|
|
||||||
document.getElementById('n-enabled').addEventListener('change', toggle);
|
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNotifSection(p) {
|
|
||||||
if (!p) return ''; // API call failed, skip silently
|
|
||||||
|
|
||||||
if (!p.smtp_enabled) {
|
|
||||||
return `
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p style="color:var(--text-muted);font-size:13px;">
|
|
||||||
Email notifications have not been configured by the admin.
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!p.allow_user_config) {
|
|
||||||
return `
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p style="color:var(--text-muted);font-size:13px;">
|
|
||||||
Email notifications are enabled and managed by the admin.
|
|
||||||
Your bills will generate reminders automatically.
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full user-configurable notification section
|
|
||||||
const chk = (id, label, val) =>
|
|
||||||
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:normal;color:var(--text)">
|
|
||||||
<input type="checkbox" id="${id}" ${val ? 'checked' : ''}> ${label}
|
|
||||||
</label>`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<form id="notif-user-form">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label>Enable Notifications</label>
|
|
||||||
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-weight:normal;color:var(--text)">
|
|
||||||
<input type="checkbox" id="n-enabled" ${p.notifications_enabled ? 'checked' : ''}>
|
|
||||||
Send me email reminders
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="n-options">
|
|
||||||
<div class="settings-row" style="margin-top:6px">
|
|
||||||
<label for="n-email">Notification Email</label>
|
|
||||||
<input type="email" id="n-email" value="${escHtml(p.notification_email)}"
|
|
||||||
placeholder="your@email.com" style="max-width:280px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-row" style="align-items:flex-start">
|
|
||||||
<label style="padding-top:2px">Remind me</label>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
|
||||||
${chk('n-3d', '3 days before due', p.notify_3d)}
|
|
||||||
${chk('n-1d', '1 day before due', p.notify_1d)}
|
|
||||||
${chk('n-due', 'On the day it\'s due', p.notify_due)}
|
|
||||||
${chk('n-overdue', 'Daily while overdue', p.notify_overdue)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:12px">
|
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Save Notifications</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/* ── Status page ── */
|
|
||||||
|
|
||||||
const StatusPage = (() => {
|
|
||||||
async function init(container) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Server Status</h1>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="status-refresh">↻ Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div id="status-body"><div class="loading">Loading...</div></div>
|
|
||||||
`;
|
|
||||||
document.getElementById('status-refresh').onclick = () => load(container);
|
|
||||||
load(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(container) {
|
|
||||||
const body = document.getElementById('status-body');
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/status');
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const d = await res.json();
|
|
||||||
body.innerHTML = render(d);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = `<div class="empty-state"><p>Failed to load status: ${e.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtUptime(seconds) {
|
|
||||||
const d = Math.floor(seconds / 86400);
|
|
||||||
const h = Math.floor((seconds % 86400) / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
|
||||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
|
||||||
if (m > 0) return `${m}m ${s}s`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(label, value, note = '') {
|
|
||||||
return `
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">${label}</span>
|
|
||||||
<span class="stat-value">${value}${note ? `<span class="stat-note">${note}</span>` : ''}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(d) {
|
|
||||||
const dbOk = d.database.status === 'connected';
|
|
||||||
return `
|
|
||||||
<div class="status-grid">
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Application</span>
|
|
||||||
<span class="status-dot dot-green" title="Online"></span>
|
|
||||||
</div>
|
|
||||||
${row('Version', `v${d.app.version}`)}
|
|
||||||
${row('Environment', d.app.environment)}
|
|
||||||
${row('Uptime', fmtUptime(d.app.uptime_seconds))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Runtime</span>
|
|
||||||
</div>
|
|
||||||
${row('Node.js', d.runtime.node_version)}
|
|
||||||
${row('Platform', `${d.runtime.platform} / ${d.runtime.arch}`)}
|
|
||||||
${row('Memory', `${d.runtime.memory_mb} MB`)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Database</span>
|
|
||||||
<span class="status-dot ${dbOk ? 'dot-green' : 'dot-red'}" title="${d.database.status}"></span>
|
|
||||||
</div>
|
|
||||||
${row('Status', dbOk ? 'Connected' : 'Error')}
|
|
||||||
${row('Size', fmtBytes(d.database.size_bytes))}
|
|
||||||
${row('File', `<code style="font-size:11px">${d.database.path}</code>`)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Statistics</span>
|
|
||||||
</div>
|
|
||||||
${row('Active Bills', d.stats.active_bills)}
|
|
||||||
${row('Total Payments', d.stats.total_payments)}
|
|
||||||
${row('Users', d.stats.users)}
|
|
||||||
${row('Active Sessions', d.stats.active_sessions)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,424 +0,0 @@
|
||||||
/* ── Tracker page ── */
|
|
||||||
|
|
||||||
const TrackerPage = (() => {
|
|
||||||
let currentYear, currentMonth;
|
|
||||||
let trackerData = null;
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
'January','February','March','April','May','June',
|
|
||||||
'July','August','September','October','November','December',
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_META = {
|
|
||||||
paid: { label: 'Paid', cls: 'badge-paid' },
|
|
||||||
upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
|
|
||||||
due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
|
|
||||||
late: { label: 'Late', cls: 'badge-late' },
|
|
||||||
missed: { label: 'Missed', cls: 'badge-missed' },
|
|
||||||
autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmt(amount) {
|
|
||||||
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const [y, m, d] = dateStr.split('-');
|
|
||||||
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayStr() {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(container) {
|
|
||||||
const now = new Date();
|
|
||||||
currentYear = now.getFullYear();
|
|
||||||
currentMonth = now.getMonth() + 1;
|
|
||||||
render(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(container) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Tracker</h1>
|
|
||||||
<div class="month-nav">
|
|
||||||
<button class="btn btn-ghost btn-sm" id="prev-month">←</button>
|
|
||||||
<span class="month-label" id="month-label"></span>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="next-month">→</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="today-btn">Today</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-bar" id="summary-bar"></div>
|
|
||||||
<div id="tracker-body"><div class="loading">Loading...</div></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('month-label').textContent =
|
|
||||||
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
|
|
||||||
|
|
||||||
document.getElementById('prev-month').onclick = () => navigate(-1, container);
|
|
||||||
document.getElementById('next-month').onclick = () => navigate(1, container);
|
|
||||||
document.getElementById('today-btn').onclick = () => {
|
|
||||||
const now = new Date();
|
|
||||||
currentYear = now.getFullYear();
|
|
||||||
currentMonth = now.getMonth() + 1;
|
|
||||||
loadData(container);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(delta, container) {
|
|
||||||
currentMonth += delta;
|
|
||||||
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
|
|
||||||
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
|
|
||||||
document.getElementById('month-label').textContent =
|
|
||||||
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
|
|
||||||
loadData(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData(container) {
|
|
||||||
document.getElementById('tracker-body').innerHTML = '<div class="loading">Loading...</div>';
|
|
||||||
try {
|
|
||||||
trackerData = await API.tracker(currentYear, currentMonth);
|
|
||||||
renderSummary(trackerData.summary);
|
|
||||||
renderRows(trackerData.rows, container);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('tracker-body').innerHTML =
|
|
||||||
`<div class="empty-state"><p>Failed to load tracker: ${e.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummary(s) {
|
|
||||||
document.getElementById('summary-bar').innerHTML = `
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="label">Total Expected</div>
|
|
||||||
<div class="value">${fmt(s.total_expected)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card success">
|
|
||||||
<div class="label">Total Paid</div>
|
|
||||||
<div class="value">${fmt(s.total_paid)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="label">Remaining</div>
|
|
||||||
<div class="value">${fmt(s.remaining)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card ${s.overdue > 0 ? 'danger' : ''}">
|
|
||||||
<div class="label">Overdue</div>
|
|
||||||
<div class="value">${fmt(s.overdue)}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRows(rows, container) {
|
|
||||||
const body = document.getElementById('tracker-body');
|
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
body.innerHTML = `<div class="empty-state">
|
|
||||||
<p>No bills this month. <a href="#bills" class="btn-link">Add a bill</a></p>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = rows.filter(r => r.bucket === '1st');
|
|
||||||
const second = rows.filter(r => r.bucket === '15th');
|
|
||||||
|
|
||||||
body.innerHTML = '';
|
|
||||||
if (first.length) body.appendChild(renderBucket('1st–14th', first));
|
|
||||||
if (second.length) body.appendChild(renderBucket('15th–31st', second));
|
|
||||||
|
|
||||||
// Attach event listeners after render
|
|
||||||
attachTableListeners(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBucket(label, rows) {
|
|
||||||
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
|
|
||||||
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
|
|
||||||
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'bucket-section';
|
|
||||||
section.innerHTML = `
|
|
||||||
<div class="bucket-header">
|
|
||||||
<span class="bucket-label">${label}</span>
|
|
||||||
<span class="bucket-totals">${fmt(totalPaid)} / ${fmt(totalExpected)}</span>
|
|
||||||
</div>
|
|
||||||
<table class="tracker-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bill</th>
|
|
||||||
<th>Due</th>
|
|
||||||
<th>Expected</th>
|
|
||||||
<th>Amount Paid</th>
|
|
||||||
<th>Paid Date</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${rows.map(renderRow).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRow(row) {
|
|
||||||
const meta = STATUS_META[row.status] || STATUS_META.upcoming;
|
|
||||||
const rowCls = `row-${row.status}`;
|
|
||||||
|
|
||||||
const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
|
|
||||||
const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
|
|
||||||
const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
|
|
||||||
const isPaid = row.status === 'paid' || row.status === 'autodraft';
|
|
||||||
|
|
||||||
const autopayDot = row.autopay_enabled
|
|
||||||
? `<span class="autopay-dot" title="Autopay"></span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr class="${rowCls}" data-bill-id="${row.id}">
|
|
||||||
<td>
|
|
||||||
<div class="td-inner bill-name-cell">
|
|
||||||
${autopayDot}
|
|
||||||
<div>
|
|
||||||
<div>${escHtml(row.name)}</div>
|
|
||||||
${row.category_name ? `<div class="bill-category">${escHtml(row.category_name)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">${fmtDate(row.due_date)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner amount-expected">${fmt(row.expected_amount)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">
|
|
||||||
<span class="editable-cell amount-cell ${paidAmt ? '' : 'empty'} ${mismatch ? 'amount-mismatch' : ''}"
|
|
||||||
data-bill-id="${row.id}" data-field="amount"
|
|
||||||
title="Click to edit payment amount">
|
|
||||||
${paidAmt || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">
|
|
||||||
<span class="editable-cell date-cell ${paidDate ? '' : 'empty'}"
|
|
||||||
data-bill-id="${row.id}" data-field="date"
|
|
||||||
title="Click to edit paid date">
|
|
||||||
${paidDate || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">
|
|
||||||
<span class="badge ${meta.cls}">${meta.label}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="action-cell">
|
|
||||||
${!isPaid ? `
|
|
||||||
<div class="quick-pay-group">
|
|
||||||
<input type="number" class="quick-pay-amount" min="0" step="0.01"
|
|
||||||
value="${row.expected_amount}"
|
|
||||||
data-bill-id="${row.id}"
|
|
||||||
title="Payment amount">
|
|
||||||
<button class="btn-pay btn-quick-pay" data-bill-id="${row.id}"
|
|
||||||
title="Mark paid today">Pay</button>
|
|
||||||
</div>` : ''}
|
|
||||||
${row.payments && row.payments.length > 0
|
|
||||||
? `<button class="btn-icon btn-edit-payment"
|
|
||||||
data-bill-id="${row.id}"
|
|
||||||
data-payment='${JSON.stringify(row.payments[0])}'
|
|
||||||
title="Edit payment">✎</button>`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachTableListeners(container) {
|
|
||||||
// Quick pay buttons — read amount from the sibling input
|
|
||||||
container.querySelectorAll('.btn-quick-pay').forEach(btn => {
|
|
||||||
btn.onclick = async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const billId = btn.dataset.billId;
|
|
||||||
const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
|
|
||||||
const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
|
|
||||||
if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
|
|
||||||
showToast('Marked as paid', 'success');
|
|
||||||
loadData(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit payment buttons
|
|
||||||
container.querySelectorAll('.btn-edit-payment').forEach(btn => {
|
|
||||||
btn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const payment = JSON.parse(btn.dataset.payment);
|
|
||||||
openPaymentModal(payment, () => loadData(container));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inline editable amount cells
|
|
||||||
container.querySelectorAll('.amount-cell').forEach(cell => {
|
|
||||||
cell.onclick = () => startInlineEdit(cell, 'number', container);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inline editable date cells
|
|
||||||
container.querySelectorAll('.date-cell').forEach(cell => {
|
|
||||||
cell.onclick = () => startInlineEdit(cell, 'date', container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startInlineEdit(cell, type, container) {
|
|
||||||
if (cell.querySelector('input')) return; // already editing
|
|
||||||
|
|
||||||
const billId = cell.dataset.billId;
|
|
||||||
const field = cell.dataset.field;
|
|
||||||
const row = trackerData?.rows?.find(r => r.id == billId);
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
let currentVal = '';
|
|
||||||
if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
|
|
||||||
if (field === 'date') currentVal = row.last_paid_date || '';
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = type === 'date' ? 'date' : 'number';
|
|
||||||
if (type === 'number') { input.step = '0.01'; input.min = '0'; }
|
|
||||||
input.value = currentVal;
|
|
||||||
input.style.cssText = 'width:100%;min-width:80px;';
|
|
||||||
|
|
||||||
const origText = cell.textContent.trim();
|
|
||||||
cell.textContent = '';
|
|
||||||
cell.appendChild(input);
|
|
||||||
cell.classList.remove('empty');
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
|
|
||||||
async function commit() {
|
|
||||||
const val = input.value.trim();
|
|
||||||
if (!val) { cell.textContent = origText || '—'; cell.classList.add('empty'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (row.payments && row.payments.length > 0) {
|
|
||||||
const p = row.payments[0];
|
|
||||||
const update = {};
|
|
||||||
if (field === 'amount') update.amount = parseFloat(val);
|
|
||||||
if (field === 'date') update.paid_date = val;
|
|
||||||
await API.updatePayment(p.id, update);
|
|
||||||
} else {
|
|
||||||
// Create new payment
|
|
||||||
const paidDate = field === 'date' ? val : todayStr();
|
|
||||||
const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
|
|
||||||
await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
|
|
||||||
}
|
|
||||||
showToast('Saved', 'success');
|
|
||||||
loadData(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
cell.textContent = origText || '—';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('blur', commit);
|
|
||||||
input.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') input.blur();
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
cell.textContent = origText || '—';
|
|
||||||
if (!origText) cell.classList.add('empty');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPaymentModal(payment, onSave) {
|
|
||||||
const modal = document.getElementById('payment-modal');
|
|
||||||
|
|
||||||
document.getElementById('payment-bill-id').value = payment.bill_id;
|
|
||||||
document.getElementById('payment-id').value = payment.id;
|
|
||||||
document.getElementById('payment-amount').value = payment.amount;
|
|
||||||
document.getElementById('payment-date').value = payment.paid_date;
|
|
||||||
document.getElementById('payment-method').value = payment.method || '';
|
|
||||||
document.getElementById('payment-notes').value = payment.notes || '';
|
|
||||||
|
|
||||||
document.getElementById('payment-modal-title').textContent = 'Edit Payment';
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
const close = () => modal.classList.add('hidden');
|
|
||||||
|
|
||||||
document.getElementById('payment-modal-close').onclick = close;
|
|
||||||
document.getElementById('payment-modal-cancel').onclick = close;
|
|
||||||
modal.querySelector('.modal-overlay').onclick = close;
|
|
||||||
|
|
||||||
// ✅ UPDATED DELETE LOGIC WITH CLEAR INTENT
|
|
||||||
document.getElementById('payment-delete').onclick = async () => {
|
|
||||||
const confirmDelete = confirm(
|
|
||||||
'Remove this payment?\n\n' +
|
|
||||||
'- The BILL will NOT be deleted\n' +
|
|
||||||
'- This will remove the payment record\n' +
|
|
||||||
'- The bill will become UNPAID\n\n' +
|
|
||||||
'Continue?'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmDelete) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await API.deletePayment(payment.id);
|
|
||||||
|
|
||||||
close();
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
'Payment removed. Bill is now marked as unpaid.',
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
onSave(); // refresh tracker
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Error: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// normal save logic untouched
|
|
||||||
document.getElementById('payment-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
amount: parseFloat(document.getElementById('payment-amount').value),
|
|
||||||
paid_date: document.getElementById('payment-date').value,
|
|
||||||
method: document.getElementById('payment-method').value || null,
|
|
||||||
notes: document.getElementById('payment-notes').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await API.updatePayment(payment.id, data);
|
|
||||||
|
|
||||||
close();
|
|
||||||
showToast('Payment saved', 'success');
|
|
||||||
onSave();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str || '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bill Tracker — Sign In</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
:root {
|
|
||||||
--bg: #0d1526;
|
|
||||||
--surface: #162236;
|
|
||||||
--surface-2: #1c2d47;
|
|
||||||
--border: rgba(255,255,255,0.07);
|
|
||||||
--border-strong: rgba(255,255,255,0.14);
|
|
||||||
--text: #e2e8f0;
|
|
||||||
--text-muted: #8faab8;
|
|
||||||
--text-faint: #506070;
|
|
||||||
--primary: #6366f1;
|
|
||||||
--primary-hover: #4f46e5;
|
|
||||||
--primary-light: rgba(99,102,241,0.18);
|
|
||||||
--danger: #f43f5e;
|
|
||||||
--danger-light: rgba(244,63,94,0.15);
|
|
||||||
--radius: 6px;
|
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(ellipse at 20% 50%, rgba(99,102,241,0.06) 0%, transparent 60%),
|
|
||||||
radial-gradient(ellipse at 80% 20%, rgba(34,211,165,0.04) 0%, transparent 50%);
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 36px 32px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5), 0 0 0 1px var(--border);
|
|
||||||
}
|
|
||||||
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
|
|
||||||
.logo-icon {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-weight: 800; font-size: 17px; color: white;
|
|
||||||
box-shadow: 0 0 16px rgba(99,102,241,0.4);
|
|
||||||
}
|
|
||||||
.logo-text { font-size: 18px; font-weight: 700; color: var(--text); }
|
|
||||||
h2 { font-size: 15px; font-weight: 500; margin-bottom: 22px; color: var(--text-muted); }
|
|
||||||
.form-group { margin-bottom: 14px; }
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .05em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 11px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-2);
|
|
||||||
transition: border-color .15s, box-shadow .15s;
|
|
||||||
}
|
|
||||||
input::placeholder { color: var(--text-faint); }
|
|
||||||
input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); }
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
margin-top: 6px;
|
|
||||||
transition: background .15s, box-shadow .15s;
|
|
||||||
}
|
|
||||||
.btn:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
|
|
||||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
|
||||||
.error {
|
|
||||||
background: var(--danger-light);
|
|
||||||
color: var(--danger);
|
|
||||||
border: 1px solid rgba(244,63,94,0.3);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 9px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.error.show { display: block; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="logo">
|
|
||||||
<div class="logo-icon">$</div>
|
|
||||||
<span class="logo-text">BillTracker</span>
|
|
||||||
</div>
|
|
||||||
<h2>Sign in to your account</h2>
|
|
||||||
<div class="error" id="error-msg"></div>
|
|
||||||
<form id="login-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input type="text" id="username" autocomplete="username" autocapitalize="none" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input type="password" id="password" autocomplete="current-password" required>
|
|
||||||
</div>
|
|
||||||
<button class="btn" type="submit" id="submit-btn">Sign In</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// If single-user mode is active, no login needed — go straight to the app
|
|
||||||
fetch('/api/auth/mode').then(r => r.json()).then(d => {
|
|
||||||
if (d.auth_mode === 'single') { location.href = '/'; return; }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect if already logged in
|
|
||||||
fetch('/api/auth/me').then(r => {
|
|
||||||
if (r.ok) return r.json().then(d => {
|
|
||||||
location.href = d.user.role === 'admin' ? '/admin.html' : '/';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('login-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = document.getElementById('submit-btn');
|
|
||||||
const err = document.getElementById('error-msg');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Signing in…';
|
|
||||||
err.classList.remove('show');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: document.getElementById('username').value,
|
|
||||||
password: document.getElementById('password').value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
|
||||||
location.href = data.user.role === 'admin' ? '/admin.html' : '/';
|
|
||||||
} catch (ex) {
|
|
||||||
err.textContent = ex.message;
|
|
||||||
err.classList.add('show');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Sign In';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,766 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bill Tracker — Admin</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
:root {
|
|
||||||
--bg: #0d1526;
|
|
||||||
--surface: #162236;
|
|
||||||
--surface-2: #1c2d47;
|
|
||||||
--surface-3: #223558;
|
|
||||||
--border: rgba(255,255,255,0.07);
|
|
||||||
--border-strong: rgba(255,255,255,0.14);
|
|
||||||
--text: #e2e8f0;
|
|
||||||
--text-muted: #8faab8;
|
|
||||||
--text-faint: #506070;
|
|
||||||
--primary: #6366f1;
|
|
||||||
--primary-hover: #4f46e5;
|
|
||||||
--primary-light: rgba(99,102,241,0.18);
|
|
||||||
--danger: #f43f5e;
|
|
||||||
--danger-light: rgba(244,63,94,0.15);
|
|
||||||
--success: #22d3a5;
|
|
||||||
--success-light: rgba(34,211,165,0.15);
|
|
||||||
--warning: #fb923c;
|
|
||||||
--warning-light: rgba(251,146,60,0.15);
|
|
||||||
--radius: 6px;
|
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
body { font-family: var(--font); font-size: 14px; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
||||||
header { background: #0a1120; color: var(--text); padding: 0 24px; height: 52px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); }
|
|
||||||
.logo { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.logo-icon { width: 28px; height: 28px; background: var(--primary); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 800; color: white; font-size: 13px; box-shadow: 0 0 10px rgba(99,102,241,0.35); }
|
|
||||||
.logo-text { font-weight: 700; font-size: 15px; color: var(--text); }
|
|
||||||
.admin-badge { background: var(--warning-light); color: var(--warning); border: 1px solid rgba(251,146,60,0.25); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; padding: 2px 7px; border-radius: 20px; margin-left: 4px; }
|
|
||||||
header .spacer { flex: 1; }
|
|
||||||
#admin-name { color: var(--text-faint); font-size: 13px; margin-right: 8px; }
|
|
||||||
.btn-logout { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border-strong); padding: 6px 12px; border-radius: var(--radius); font-size: 13px; cursor: pointer; transition: background .15s, color .15s; }
|
|
||||||
.btn-logout:hover { background: var(--surface-3); color: var(--text); }
|
|
||||||
main { max-width: 700px; margin: 32px auto; padding: 0 20px; }
|
|
||||||
|
|
||||||
/* ── Onboarding wizard ── */
|
|
||||||
#onboarding { display: none; }
|
|
||||||
.wizard-step { display: none; }
|
|
||||||
.wizard-step.active { display: block; }
|
|
||||||
.wizard-card { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border); }
|
|
||||||
.wizard-icon { font-size: 32px; margin-bottom: 16px; }
|
|
||||||
.wizard-card h2 { font-size: 20px; font-weight: 700; margin-bottom: 8px; color: var(--text); }
|
|
||||||
.wizard-card .sub { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; line-height: 1.6; }
|
|
||||||
.capability-list { list-style: none; margin: 0 0 24px; }
|
|
||||||
.capability-list li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
|
||||||
.capability-list li:last-child { border-bottom: none; }
|
|
||||||
.cap-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
|
|
||||||
.cap-can { color: var(--success); }
|
|
||||||
.cap-cant { color: var(--danger); }
|
|
||||||
.cap-text { line-height: 1.5; }
|
|
||||||
.cap-text strong { display: block; color: var(--text); }
|
|
||||||
.cap-text span { color: var(--text-muted); font-size: 13px; }
|
|
||||||
|
|
||||||
/* ── Normal admin panel ── */
|
|
||||||
#panel { display: none; }
|
|
||||||
.notice { background: var(--warning-light); border: 1px solid rgba(251,146,60,0.25); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px; }
|
|
||||||
.notice strong { color: var(--warning); display: block; margin-bottom: 4px; font-size: 13px; }
|
|
||||||
.notice ul { margin: 8px 0 0 20px; font-size: 13px; color: var(--text-muted); }
|
|
||||||
.notice li { margin-bottom: 2px; }
|
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border); }
|
|
||||||
.card h2 { font-size: 15px; font-weight: 700; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 6px 10px; border-bottom: 1px solid var(--border-strong); background: var(--surface-2); }
|
|
||||||
td { padding: 10px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text); }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr:hover td { background: var(--surface-2); }
|
|
||||||
.role-badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
|
||||||
.role-admin { background: var(--warning-light); color: var(--warning); }
|
|
||||||
.role-user { background: var(--primary-light); color: var(--primary); }
|
|
||||||
|
|
||||||
/* ── Shared form/button styles ── */
|
|
||||||
.btn { display: inline-flex; align-items: center; gap: 4px; padding: 8px 16px; border: none; border-radius: var(--radius); font-size: 13px; font-family: var(--font); font-weight: 600; cursor: pointer; transition: background .15s, box-shadow .15s; }
|
|
||||||
.btn-primary { background: var(--primary); color: white; }
|
|
||||||
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
|
|
||||||
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-strong); }
|
|
||||||
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-danger:hover:not(:disabled) { background: #e11d48; }
|
|
||||||
.btn-sm { padding: 4px 9px; font-size: 12px; }
|
|
||||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
||||||
.btn-full { width: 100%; justify-content: center; }
|
|
||||||
.add-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
.form-group label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
|
|
||||||
input[type="text"], input[type="password"], input[type="email"], input[type="number"], select { padding: 8px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius); font-size: 13px; font-family: var(--font); color: var(--text); background: var(--surface-2); width: 100%; transition: border-color .15s, box-shadow .15s; }
|
|
||||||
input::placeholder, select option[disabled] { color: var(--text-faint); }
|
|
||||||
select option { background: var(--surface-2); color: var(--text); }
|
|
||||||
input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
|
|
||||||
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); }
|
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
|
||||||
.err { color: var(--danger); font-size: 12px; margin-top: 6px; min-height: 16px; }
|
|
||||||
.ok { color: var(--success); font-size: 12px; margin-top: 6px; min-height: 16px; }
|
|
||||||
.reset-form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.reset-form input { min-width: 120px; padding: 4px 8px; font-size: 12px; }
|
|
||||||
.muted { color: var(--text-muted); font-size: 12px; }
|
|
||||||
code { background: var(--surface-3); color: #38bdf8; padding: 1px 5px; border-radius: 3px; font-size: 12px; font-family: 'SF Mono', monospace; }
|
|
||||||
#panel-status { font-size: 13px; margin-top: 4px; min-height: 18px; }
|
|
||||||
.step-dots { display: flex; gap: 6px; justify-content: center; margin-bottom: 24px; }
|
|
||||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--surface-3); transition: background .2s; }
|
|
||||||
.dot.active { background: var(--primary); }
|
|
||||||
|
|
||||||
/* Email settings form */
|
|
||||||
.smtp-form { display: grid; grid-template-columns: 220px 1fr; gap: 10px 16px; align-items: center; }
|
|
||||||
.smtp-form .row-label { font-size: 13px; font-weight: 500; color: var(--text); }
|
|
||||||
.smtp-form .row-sub { font-size: 11px; color: var(--text-faint); margin-top:1px; }
|
|
||||||
.smtp-form .row-ctrl { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.smtp-divider { grid-column: 1/-1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
|
||||||
.pw-wrap { position: relative; flex: 1; }
|
|
||||||
.pw-wrap input { padding-right: 36px; }
|
|
||||||
.pw-toggle { position:absolute; right:8px; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; color:var(--text-faint); font-size:16px; line-height:1; }
|
|
||||||
.pw-toggle:hover { color: var(--text); }
|
|
||||||
.enabled-badge { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:20px; font-size:11px; font-weight:700; }
|
|
||||||
.badge-on { background:var(--success-light); color:var(--success); }
|
|
||||||
.badge-off { background:var(--surface-2); color:var(--text-muted); }
|
|
||||||
.test-row { grid-column:1/-1; display:flex; gap:8px; align-items:center; margin-top:4px; }
|
|
||||||
.notif-actions { grid-column:1/-1; display:flex; gap:8px; justify-content:flex-end; margin-top:8px; }
|
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
|
||||||
::-webkit-scrollbar { width: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<div class="logo-icon">$</div>
|
|
||||||
<span class="logo-text">BillTracker</span>
|
|
||||||
<span class="admin-badge">Admin</span>
|
|
||||||
</div>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<span id="admin-name"></span>
|
|
||||||
<button class="btn-logout" id="logout-btn">Sign Out</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ── Onboarding wizard (shown when no users exist yet) ───────────────── -->
|
|
||||||
<div id="onboarding">
|
|
||||||
<main>
|
|
||||||
<div class="step-dots">
|
|
||||||
<div class="dot active" id="dot-1"></div>
|
|
||||||
<div class="dot" id="dot-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 1: Explain the admin account -->
|
|
||||||
<div class="wizard-step active" id="step-1">
|
|
||||||
<div class="wizard-card">
|
|
||||||
<div class="wizard-icon">🔒</div>
|
|
||||||
<h2>Welcome to Bill Tracker</h2>
|
|
||||||
<p class="sub">
|
|
||||||
You're logged in as the <strong>admin</strong>. Before you get started,
|
|
||||||
here's exactly what this account can and cannot do.
|
|
||||||
</p>
|
|
||||||
<ul class="capability-list">
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-can">✓</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Create user accounts</strong>
|
|
||||||
<span>You control who can log in.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-can">✓</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Reset user passwords</strong>
|
|
||||||
<span>If a user gets locked out, you can reset their password.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-cant">✗</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Cannot access any financial data</strong>
|
|
||||||
<span>Bills, payments, and tracker data are completely off-limits to this account — by design.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="cap-icon cap-cant">✗</span>
|
|
||||||
<span class="cap-text">
|
|
||||||
<strong>Cannot view account balances or history</strong>
|
|
||||||
<span>Your financial privacy is enforced at the API level, not just the UI.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-primary btn-full" id="step1-next">
|
|
||||||
Got it — create my user account →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Create first user -->
|
|
||||||
<div class="wizard-step" id="step-2">
|
|
||||||
<div class="wizard-card">
|
|
||||||
<div class="wizard-icon">👤</div>
|
|
||||||
<h2>Create Your User Account</h2>
|
|
||||||
<p class="sub">
|
|
||||||
This account will have full access to the tracker, bills, and payments.
|
|
||||||
Use this account for your day-to-day bill tracking.
|
|
||||||
</p>
|
|
||||||
<form id="onboarding-form">
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" id="ob-username" autocapitalize="none" minlength="3" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="grid-column:1/-1"><!-- spacer --></div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Password</label>
|
|
||||||
<input type="password" id="ob-password" minlength="8" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Confirm Password</label>
|
|
||||||
<input type="password" id="ob-confirm" minlength="8" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="err" id="ob-error"></div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-full" id="ob-submit">
|
|
||||||
Create Account & Go to Admin Panel
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-full" id="step2-back"
|
|
||||||
style="margin-top:8px">
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Normal admin panel ──────────────────────────────────────────────── -->
|
|
||||||
<div id="panel">
|
|
||||||
<main>
|
|
||||||
<div class="notice">
|
|
||||||
<strong>Admin Account Scope</strong>
|
|
||||||
<ul>
|
|
||||||
<li>You can create user accounts and reset passwords.</li>
|
|
||||||
<li>You cannot view, access, or modify any bills, payments, or financial data.</li>
|
|
||||||
<li>Users are informed that only their password can be reset by this account.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="email-notif-card">
|
|
||||||
<h2>Email Notifications</h2>
|
|
||||||
<div id="email-notif-content"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="auth-mode-card">
|
|
||||||
<h2>Login Mode</h2>
|
|
||||||
<div id="auth-mode-content"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Add User</h2>
|
|
||||||
<form class="add-form" id="add-user-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" id="new-username" autocapitalize="none" minlength="3" required style="min-width:160px">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Password</label>
|
|
||||||
<input type="password" id="new-password" minlength="8" required style="min-width:160px">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary" style="align-self:flex-end">Create User</button>
|
|
||||||
</form>
|
|
||||||
<div id="add-status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Users</h2>
|
|
||||||
<div id="users-table"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const res = await fetch('/api/auth/me');
|
|
||||||
if (!res.ok) { location.href = '/login.html'; return; }
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.user.role !== 'admin') { location.href = '/'; return; }
|
|
||||||
currentUser = data.user;
|
|
||||||
document.getElementById('admin-name').textContent = data.user.username;
|
|
||||||
|
|
||||||
const hasRes = await fetch('/api/admin/has-users');
|
|
||||||
const hasData = await hasRes.json();
|
|
||||||
|
|
||||||
if (!hasData.has_users) {
|
|
||||||
showOnboarding();
|
|
||||||
} else {
|
|
||||||
showPanel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Onboarding ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showOnboarding() {
|
|
||||||
document.getElementById('onboarding').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPanel() {
|
|
||||||
document.getElementById('onboarding').style.display = 'none';
|
|
||||||
document.getElementById('panel').style.display = 'block';
|
|
||||||
loadEmailNotif();
|
|
||||||
loadAuthMode();
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStep(n) {
|
|
||||||
document.querySelectorAll('.wizard-step').forEach((el, i) => {
|
|
||||||
el.classList.toggle('active', i + 1 === n);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.dot').forEach((el, i) => {
|
|
||||||
el.classList.toggle('active', i + 1 === n);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('step1-next').onclick = () => setStep(2);
|
|
||||||
document.getElementById('step2-back').onclick = () => setStep(1);
|
|
||||||
|
|
||||||
document.getElementById('onboarding-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const username = document.getElementById('ob-username').value.trim();
|
|
||||||
const password = document.getElementById('ob-password').value;
|
|
||||||
const confirm = document.getElementById('ob-confirm').value;
|
|
||||||
const errEl = document.getElementById('ob-error');
|
|
||||||
const btn = document.getElementById('ob-submit');
|
|
||||||
|
|
||||||
errEl.textContent = '';
|
|
||||||
|
|
||||||
if (password !== confirm) { errEl.textContent = 'Passwords do not match.'; return; }
|
|
||||||
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters.'; return; }
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Creating…';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
showPanel();
|
|
||||||
} catch (err) {
|
|
||||||
errEl.textContent = err.message;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Create Account & Go to Admin Panel';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Admin panel ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ── Email notifications (admin SMTP) ──────────────────────────────────────
|
|
||||||
|
|
||||||
let smtpSettings = {};
|
|
||||||
|
|
||||||
async function loadEmailNotif() {
|
|
||||||
const res = await fetch('/api/notifications/admin');
|
|
||||||
if (!res.ok) return;
|
|
||||||
smtpSettings = await res.json();
|
|
||||||
renderEmailNotif();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmailNotif() {
|
|
||||||
const s = smtpSettings;
|
|
||||||
const enabled = s.notify_smtp_enabled === 'true';
|
|
||||||
|
|
||||||
document.getElementById('email-notif-content').innerHTML = `
|
|
||||||
<div style="margin-bottom:16px;display:flex;align-items:center;gap:10px;">
|
|
||||||
<span class="enabled-badge ${enabled ? 'badge-on' : 'badge-off'}">
|
|
||||||
${enabled ? '● Enabled' : '○ Disabled'}
|
|
||||||
</span>
|
|
||||||
<span class="muted">${enabled ? 'Sending email notifications' : 'Email notifications are off'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="smtp-form" class="smtp-form" autocomplete="off">
|
|
||||||
|
|
||||||
<label class="row-label">Enable Agent</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="checkbox" id="s-enabled" ${enabled ? 'checked' : ''}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">Sender Name</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="text" id="s-sender-name" value="${esc(s.notify_sender_name)}" style="max-width:300px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Sender Address <span style="color:var(--danger)">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="email" id="s-sender-addr" value="${esc(s.notify_sender_address)}" style="max-width:300px" placeholder="from@example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
SMTP Host <span style="color:var(--danger)">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="text" id="s-host" value="${esc(s.notify_smtp_host)}" style="max-width:300px" placeholder="smtp.example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
SMTP Port <span style="color:var(--danger)">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="number" id="s-port" value="${esc(s.notify_smtp_port || '587')}" style="max-width:90px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Encryption Method <span style="color:var(--danger)">*</span>
|
|
||||||
<div class="row-sub">STARTTLS → port 587 · SSL/TLS → port 465</div>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<select id="s-encryption" style="max-width:240px">
|
|
||||||
<option value="starttls" ${s.notify_smtp_encryption==='starttls'?'selected':''}>STARTTLS</option>
|
|
||||||
<option value="ssl" ${s.notify_smtp_encryption==='ssl'?'selected':''}>SSL / TLS</option>
|
|
||||||
<option value="none" ${s.notify_smtp_encryption==='none'?'selected':''}>None</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">Allow Self-Signed Certificates</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="checkbox" id="s-self-signed" ${s.notify_smtp_self_signed==='true'?'checked':''}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">SMTP Username</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="text" id="s-user" value="${esc(s.notify_smtp_username)}" autocomplete="new-password" style="max-width:300px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">SMTP Password</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<div class="pw-wrap" style="max-width:300px">
|
|
||||||
<input type="password" id="s-pass" value="${esc(s.notify_smtp_password)}" autocomplete="new-password">
|
|
||||||
<button type="button" class="pw-toggle" onclick="togglePw('s-pass',this)">👁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="smtp-divider">
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Allow users to configure<br>their own notification email
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="checkbox" id="s-allow-user" ${s.notify_allow_user_config==='true'?'checked':''}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="row-label">
|
|
||||||
Global recipient email
|
|
||||||
<div class="row-sub">Used when user config is disabled</div>
|
|
||||||
</label>
|
|
||||||
<div class="row-ctrl">
|
|
||||||
<input type="email" id="s-global-recipient" value="${esc(s.notify_global_recipient)}" style="max-width:300px" placeholder="you@example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-row">
|
|
||||||
<input type="email" id="s-test-to" placeholder="Send test to…" style="max-width:240px">
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="s-test-btn">Send Test Email</button>
|
|
||||||
<span id="s-test-status" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notif-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
<div id="smtp-save-status" style="text-align:right;margin-top:6px;font-size:13px;min-height:18px;"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('smtp-form').onsubmit = saveEmailNotif;
|
|
||||||
document.getElementById('s-test-btn').onclick = sendTestEmail;
|
|
||||||
|
|
||||||
// Auto-fill port on encryption change
|
|
||||||
document.getElementById('s-encryption').onchange = function() {
|
|
||||||
const portEl = document.getElementById('s-port');
|
|
||||||
if (this.value === 'ssl') portEl.value = '465';
|
|
||||||
else if (portEl.value === '465') portEl.value = '587';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEmailNotif(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
const payload = {
|
|
||||||
notify_smtp_enabled: document.getElementById('s-enabled').checked ? 'true' : 'false',
|
|
||||||
notify_sender_name: document.getElementById('s-sender-name').value,
|
|
||||||
notify_sender_address: document.getElementById('s-sender-addr').value,
|
|
||||||
notify_smtp_host: document.getElementById('s-host').value,
|
|
||||||
notify_smtp_port: document.getElementById('s-port').value,
|
|
||||||
notify_smtp_encryption: document.getElementById('s-encryption').value,
|
|
||||||
notify_smtp_self_signed: document.getElementById('s-self-signed').checked ? 'true' : 'false',
|
|
||||||
notify_smtp_username: document.getElementById('s-user').value,
|
|
||||||
notify_smtp_password: document.getElementById('s-pass').value,
|
|
||||||
notify_allow_user_config:document.getElementById('s-allow-user').checked ? 'true' : 'false',
|
|
||||||
notify_global_recipient: document.getElementById('s-global-recipient').value,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/admin', {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
|
||||||
smtpSettings = { ...smtpSettings, ...payload };
|
|
||||||
document.getElementById('smtp-save-status').innerHTML = '<span class="ok">Saved.</span>';
|
|
||||||
renderEmailNotif(); // re-render badge
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('smtp-save-status').innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTestEmail() {
|
|
||||||
const to = document.getElementById('s-test-to').value.trim();
|
|
||||||
const el = document.getElementById('s-test-status');
|
|
||||||
const btn = document.getElementById('s-test-btn');
|
|
||||||
if (!to) { el.textContent = 'Enter a recipient.'; return; }
|
|
||||||
btn.disabled = true;
|
|
||||||
el.textContent = 'Sending…';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/test', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ to }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
el.innerHTML = '<span class="ok">Sent!</span>';
|
|
||||||
} catch (err) {
|
|
||||||
el.innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePw(id, btn) {
|
|
||||||
const inp = document.getElementById(id);
|
|
||||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
|
||||||
btn.textContent = inp.type === 'password' ? '👁' : '🙈';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auth mode ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadAuthMode() {
|
|
||||||
const [modeRes, usersRes] = await Promise.all([
|
|
||||||
fetch('/api/admin/auth-mode'),
|
|
||||||
fetch('/api/admin/users'),
|
|
||||||
]);
|
|
||||||
const { auth_mode, default_user_id } = await modeRes.json();
|
|
||||||
const users = await usersRes.json();
|
|
||||||
const regularUsers = users.filter(u => u.role === 'user');
|
|
||||||
|
|
||||||
const el = document.getElementById('auth-mode-content');
|
|
||||||
|
|
||||||
if (auth_mode === 'single') {
|
|
||||||
const defaultUser = regularUsers.find(u => u.id == default_user_id);
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
||||||
<span style="background:var(--warning-light);color:var(--warning);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700">
|
|
||||||
Single-User Mode Active
|
|
||||||
</span>
|
|
||||||
<span class="muted">Auto-logged in as: <strong>${esc(defaultUser?.username || '(unknown)')}</strong></span>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="revert-multi-btn">Restore Login Requirement</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="margin-top:10px;font-size:12px;line-height:1.5">
|
|
||||||
Anyone who opens the app is automatically signed in as
|
|
||||||
<strong>${esc(defaultUser?.username || '(unknown)')}</strong> — no password required.
|
|
||||||
Only the admin login page still requires authentication.
|
|
||||||
</p>
|
|
||||||
`;
|
|
||||||
document.getElementById('revert-multi-btn').onclick = async () => {
|
|
||||||
await setAuthMode('multi', null);
|
|
||||||
loadAuthMode();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const userOptions = regularUsers.map(u =>
|
|
||||||
`<option value="${u.id}">${esc(u.username)}</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px">
|
|
||||||
<span style="background:var(--success-light);color:var(--success);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700">
|
|
||||||
Normal Login Active
|
|
||||||
</span>
|
|
||||||
<span class="muted">All users must sign in with their password.</span>
|
|
||||||
</div>
|
|
||||||
${regularUsers.length === 0
|
|
||||||
? `<p class="muted" style="font-size:12px">Create at least one user to enable single-user mode.</p>`
|
|
||||||
: `<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Auto-login as</label>
|
|
||||||
<select id="single-user-select" style="min-width:150px">
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="enable-single-btn">Enable Single-User Mode</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="margin-top:8px;font-size:12px;line-height:1.5">
|
|
||||||
Single-user mode removes the login screen. Anyone who opens the app gets
|
|
||||||
access as the selected user. The admin login at <code>/admin.html</code>
|
|
||||||
still requires a password.
|
|
||||||
</p>`
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (regularUsers.length > 0) {
|
|
||||||
document.getElementById('enable-single-btn').onclick = async () => {
|
|
||||||
const userId = document.getElementById('single-user-select').value;
|
|
||||||
if (!confirm('Enable single-user mode? Anyone who opens the app will be signed in automatically.')) return;
|
|
||||||
await setAuthMode('single', userId);
|
|
||||||
loadAuthMode();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAuthMode(mode, userId) {
|
|
||||||
const res = await fetch('/api/admin/auth-mode', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ auth_mode: mode, default_user_id: userId }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { panelStatus('Error: ' + data.error, 'err'); return; }
|
|
||||||
panelStatus(mode === 'single' ? 'Single-user mode enabled.' : 'Login restored.', 'ok');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
const res = await fetch('/api/admin/users');
|
|
||||||
if (!res.ok) return;
|
|
||||||
const users = await res.json();
|
|
||||||
|
|
||||||
const tbody = users.map(u => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(u.username)}</td>
|
|
||||||
<td><span class="role-badge role-${u.role}">${u.role}</span></td>
|
|
||||||
<td>${u.must_change_password
|
|
||||||
? '<span style="color:var(--warning)">Must change</span>'
|
|
||||||
: '<span class="muted">OK</span>'}</td>
|
|
||||||
<td>
|
|
||||||
${u.role !== 'admin' ? `
|
|
||||||
<form class="reset-form" onsubmit="resetPassword(event,${u.id})">
|
|
||||||
<input type="password" placeholder="New password" minlength="8" required>
|
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Reset</button>
|
|
||||||
<button type="button" class="btn btn-danger btn-sm"
|
|
||||||
onclick="deleteUser(${u.id},'${esc(u.username)}')">Delete</button>
|
|
||||||
</form>
|
|
||||||
` : '<span class="muted">—</span>'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
document.getElementById('users-table').innerHTML = `
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Username</th><th>Role</th><th>Password</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>${tbody || '<tr><td colspan="4" class="muted">No users yet.</td></tr>'}</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPassword(e, userId) {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.target.querySelector('input[type="password"]');
|
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/users/${userId}/password`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password: input.value }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
input.value = '';
|
|
||||||
panelStatus('Password reset. User will be prompted to change it on next login.', 'ok');
|
|
||||||
loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
panelStatus('Error: ' + err.message, 'err');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteUser(userId, username) {
|
|
||||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
panelStatus(`User "${username}" deleted.`, 'ok');
|
|
||||||
loadAuthMode();
|
|
||||||
loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
panelStatus('Error: ' + err.message, 'err');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('add-user-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
document.getElementById('add-status').textContent = '';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: document.getElementById('new-username').value,
|
|
||||||
password: document.getElementById('new-password').value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
document.getElementById('new-username').value = '';
|
|
||||||
document.getElementById('new-password').value = '';
|
|
||||||
document.getElementById('add-status').innerHTML = `<span class="ok">User "${esc(data.username)}" created.</span>`;
|
|
||||||
loadAuthMode();
|
|
||||||
loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('add-status').innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let panelTimer;
|
|
||||||
function panelStatus(msg, cls) {
|
|
||||||
const el = document.getElementById('panel-status') || (() => {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.id = 'panel-status';
|
|
||||||
document.getElementById('users-table').before(d);
|
|
||||||
return d;
|
|
||||||
})();
|
|
||||||
el.innerHTML = `<span class="${cls}">${esc(msg)}</span>`;
|
|
||||||
clearTimeout(panelTimer);
|
|
||||||
panelTimer = setTimeout(() => el.textContent = '', 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('logout-btn').onclick = async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
location.href = '/login.html';
|
|
||||||
};
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<')
|
|
||||||
.replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,888 +0,0 @@
|
||||||
/* ── Reset & base ── */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0d1526;
|
|
||||||
--surface: #162236;
|
|
||||||
--surface-2: #1c2d47;
|
|
||||||
--surface-3: #223558;
|
|
||||||
--border: rgba(255,255,255,0.07);
|
|
||||||
--border-strong: rgba(255,255,255,0.14);
|
|
||||||
--text: #e2e8f0;
|
|
||||||
--text-muted: #8faab8;
|
|
||||||
--text-faint: #506070;
|
|
||||||
--primary: #6366f1;
|
|
||||||
--primary-hover: #4f46e5;
|
|
||||||
--primary-light: rgba(99,102,241,0.18);
|
|
||||||
--success: #22d3a5;
|
|
||||||
--success-light: rgba(34,211,165,0.15);
|
|
||||||
--danger: #f43f5e;
|
|
||||||
--danger-light: rgba(244,63,94,0.15);
|
|
||||||
--warning: #fb923c;
|
|
||||||
--warning-light: rgba(251,146,60,0.15);
|
|
||||||
--info: #38bdf8;
|
|
||||||
--info-light: rgba(56,189,248,0.15);
|
|
||||||
--sidebar-w: 200px;
|
|
||||||
--header-h: 56px;
|
|
||||||
--radius: 6px;
|
|
||||||
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border);
|
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
|
|
||||||
|
|
||||||
/* ── Layout ── */
|
|
||||||
#app {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: var(--sidebar-w);
|
|
||||||
background: #0a1120;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; bottom: 0;
|
|
||||||
z-index: 100;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 20px 16px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 30px; height: 30px;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-weight: 800; font-size: 15px; color: white;
|
|
||||||
box-shadow: 0 0 12px rgba(99,102,241,0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links li { margin: 2px 0; }
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13.5px;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
.nav-link.active { background: var(--primary-light); color: var(--primary); }
|
|
||||||
|
|
||||||
.nav-icon { font-size: 12px; opacity: .8; }
|
|
||||||
|
|
||||||
.sidebar-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 8px 8px 12px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar-username {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: 4px 10px 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logout {
|
|
||||||
font-size: 12px !important;
|
|
||||||
opacity: .7;
|
|
||||||
}
|
|
||||||
.sidebar-logout:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: var(--sidebar-w);
|
|
||||||
flex: 1;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page { display: none; }
|
|
||||||
.page.active { display: block; }
|
|
||||||
|
|
||||||
/* ── Page header ── */
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Summary cards ── */
|
|
||||||
.summary-bar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 16px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0; left: 0; right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.danger::before { background: var(--danger); }
|
|
||||||
.summary-card.success::before { background: var(--success); }
|
|
||||||
.summary-card.warning::before { background: var(--warning); }
|
|
||||||
|
|
||||||
.summary-card .label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card .value {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.danger .value { color: var(--danger); }
|
|
||||||
.summary-card.success .value { color: var(--success); }
|
|
||||||
.summary-card.warning .value { color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Month nav ── */
|
|
||||||
.month-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-nav .month-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 130px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tracker table ── */
|
|
||||||
.bucket-section {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-bottom: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .07em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-totals {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table th {
|
|
||||||
background: var(--surface-2);
|
|
||||||
padding: 9px 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 1px solid var(--border-strong);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table td {
|
|
||||||
padding: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-table tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.tracker-table tr:hover td { background: var(--surface-2); }
|
|
||||||
.tracker-table tr.row-paid:hover td { background: rgba(34,211,165,0.07); }
|
|
||||||
.tracker-table tr.row-late:hover td { background: rgba(251,146,60,0.07); }
|
|
||||||
.tracker-table tr.row-missed:hover td { background: rgba(244,63,94,0.07); }
|
|
||||||
|
|
||||||
.tracker-table tr.row-paid td { background: rgba(34,211,165,0.04); }
|
|
||||||
.tracker-table tr.row-late td { background: rgba(251,146,60,0.04); }
|
|
||||||
.tracker-table tr.row-missed td { background: rgba(244,63,94,0.04); }
|
|
||||||
.tracker-table tr.row-autodraft td { background: rgba(251,146,60,0.04); }
|
|
||||||
|
|
||||||
.td-inner {
|
|
||||||
padding: 10px 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status badge ── */
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-paid { background: var(--success-light); color: var(--success); }
|
|
||||||
.badge-upcoming { background: var(--surface-3); color: var(--text-muted); }
|
|
||||||
.badge-due-soon { background: var(--warning-light); color: var(--warning); }
|
|
||||||
.badge-late { background: var(--warning-light); color: var(--warning); }
|
|
||||||
.badge-missed { background: var(--danger-light); color: var(--danger); }
|
|
||||||
.badge-autodraft { background: var(--warning-light); color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Inline editable cells ── */
|
|
||||||
.editable-cell {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
min-width: 80px;
|
|
||||||
transition: background .1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-cell:hover { background: var(--primary-light); }
|
|
||||||
.editable-cell.empty { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.editable-cell input {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
color: var(--text);
|
|
||||||
width: 100%;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bill name cell ── */
|
|
||||||
.bill-name-cell {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.bill-category {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Quick pay group ── */
|
|
||||||
.quick-pay-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-pay-group input[type="number"] {
|
|
||||||
width: 80px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Action buttons ── */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s, opacity .15s, box-shadow .15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-primary { background: var(--primary); color: white; }
|
|
||||||
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
|
|
||||||
|
|
||||||
.btn-success { background: var(--success); color: #0d1526; }
|
|
||||||
.btn-success:hover:not(:disabled) { background: #1ab890; }
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
}
|
|
||||||
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-danger:hover:not(:disabled) { background: #e11d48; }
|
|
||||||
|
|
||||||
.btn-sm { padding: 4px 8px; font-size: 12px; }
|
|
||||||
|
|
||||||
.btn-pay {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary);
|
|
||||||
border: 1px solid rgba(99,102,241,0.3);
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
.btn-pay:hover { background: var(--primary); color: white; border-color: var(--primary); }
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-faint);
|
|
||||||
border: none;
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
|
|
||||||
/* ── Action cell ── */
|
|
||||||
.action-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Amount display ── */
|
|
||||||
.amount-expected { color: var(--text-muted); font-size: 13px; }
|
|
||||||
.amount-actual { font-weight: 600; color: var(--text); }
|
|
||||||
.amount-mismatch { color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Bills management page ── */
|
|
||||||
.bills-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bill-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
transition: border-color .15s, background .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bill-card:hover { border-color: var(--primary); background: var(--surface-2); }
|
|
||||||
.bill-card.inactive { opacity: .45; }
|
|
||||||
|
|
||||||
.bill-card-info { flex: 1; min-width: 0; }
|
|
||||||
.bill-card-name { font-weight: 600; font-size: 14px; color: var(--text); }
|
|
||||||
.bill-card-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
|
||||||
|
|
||||||
.bill-card-amount {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
min-width: 80px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bill-card-actions { display: flex; gap: 6px; }
|
|
||||||
|
|
||||||
/* ── Categories page ── */
|
|
||||||
.cat-list { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
|
|
||||||
.cat-item {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 10px 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
transition: border-color .15s;
|
|
||||||
}
|
|
||||||
.cat-item:hover { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.cat-name { flex: 1; font-weight: 500; color: var(--text); }
|
|
||||||
|
|
||||||
.cat-add-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Settings page ── */
|
|
||||||
.settings-section {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row:last-child { margin-bottom: 0; }
|
|
||||||
|
|
||||||
.settings-row label {
|
|
||||||
min-width: 180px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Forms ── */
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group.full-width { grid-column: 1 / -1; }
|
|
||||||
.form-group.checkbox-group { flex-direction: row; flex-wrap: wrap; gap: 16px; }
|
|
||||||
.form-group.checkbox-group label {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 500; color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="date"],
|
|
||||||
input[type="email"],
|
|
||||||
input[type="password"],
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-2);
|
|
||||||
transition: border-color .15s, box-shadow .15s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
select option {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder { color: var(--text-faint); }
|
|
||||||
textarea::placeholder { color: var(--text-faint); }
|
|
||||||
textarea { resize: vertical; }
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
accent-color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ── */
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.hidden { display: none; }
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.65);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-box {
|
|
||||||
position: relative;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 10px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-box-sm { max-width: 380px; }
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 22px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color .15s;
|
|
||||||
}
|
|
||||||
.modal-close:hover { color: var(--text); }
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toast notifications ── */
|
|
||||||
#toast-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
gap: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 280px;
|
|
||||||
max-width: 380px;
|
|
||||||
padding: 12px 14px 12px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-left-width: 3px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px) translateY(4px);
|
|
||||||
transition: opacity .25s ease, transform .25s ease;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.hide {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px) translateY(4px);
|
|
||||||
transition: opacity .2s ease, transform .2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-icon {
|
|
||||||
font-size: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-body { flex: 1; line-height: 1.4; }
|
|
||||||
|
|
||||||
.toast.success { border-left-color: var(--success); }
|
|
||||||
.toast.success .toast-icon { color: var(--success); }
|
|
||||||
|
|
||||||
.toast.error { border-left-color: var(--danger); }
|
|
||||||
.toast.error .toast-icon { color: var(--danger); }
|
|
||||||
|
|
||||||
.toast.warning { border-left-color: var(--warning); }
|
|
||||||
.toast.warning .toast-icon { color: var(--warning); }
|
|
||||||
|
|
||||||
.toast.info { border-left-color: var(--info); }
|
|
||||||
.toast.info .toast-icon { color: var(--info); }
|
|
||||||
|
|
||||||
/* Legacy single #toast element (backwards compatibility) */
|
|
||||||
#toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 11px 16px 11px 18px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-left: 3px solid var(--primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
||||||
z-index: 999;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
transition: opacity .25s, transform .25s;
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toast.show { opacity: 1; transform: translateX(0); }
|
|
||||||
#toast.success { border-left-color: var(--success); }
|
|
||||||
#toast.error { border-left-color: var(--danger); }
|
|
||||||
#toast.warning { border-left-color: var(--warning); }
|
|
||||||
|
|
||||||
/* ── Status page ── */
|
|
||||||
.status-page {
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px 18px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.green {
|
|
||||||
background: var(--success);
|
|
||||||
box-shadow: 0 0 0 0 rgba(34,211,165,0.4);
|
|
||||||
animation: pulse-green 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.red {
|
|
||||||
background: var(--danger);
|
|
||||||
box-shadow: 0 0 0 0 rgba(244,63,94,0.4);
|
|
||||||
animation: pulse-red 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-green {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(34,211,165,0.5); }
|
|
||||||
70% { box-shadow: 0 0 0 7px rgba(34,211,165,0); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(34,211,165,0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-red {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(244,63,94,0.5); }
|
|
||||||
70% { box-shadow: 0 0 0 7px rgba(244,63,94,0); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(244,63,94,0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 9px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.stat-row .stat-label { color: var(--text-muted); }
|
|
||||||
.stat-row .stat-value { font-weight: 600; color: var(--text); }
|
|
||||||
|
|
||||||
/* ── Misc ── */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px 20px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.empty-state p { font-size: 14px; margin-top: 8px; }
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 32px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autopay-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px; height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--warning);
|
|
||||||
margin-right: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted { color: var(--text-muted); }
|
|
||||||
.text-faint { color: var(--text-faint); }
|
|
||||||
.text-sm { font-size: 12px; }
|
|
||||||
.mt-1 { margin-top: 4px; }
|
|
||||||
.gap-8 { gap: 8px; }
|
|
||||||
|
|
||||||
/* ── Divider ── */
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Code / monospace ── */
|
|
||||||
code {
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--info);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.sidebar { width: 60px; }
|
|
||||||
.logo-text, .nav-link span:not(.nav-icon) { display: none; }
|
|
||||||
.main-content { margin-left: 60px; padding: 16px; }
|
|
||||||
.summary-bar { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB |
|
|
@ -1,234 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bill Tracker</title>
|
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<nav class="sidebar">
|
|
||||||
<div class="logo">
|
|
||||||
<span class="logo-icon">$</span>
|
|
||||||
<span class="logo-text">BillTracker</span>
|
|
||||||
</div>
|
|
||||||
<ul class="nav-links">
|
|
||||||
<li><a href="#tracker" class="nav-link active" data-page="tracker">
|
|
||||||
<span class="nav-icon">☰</span> Tracker
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#bills" class="nav-link" data-page="bills">
|
|
||||||
<span class="nav-icon">●</span> Bills
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#categories" class="nav-link" data-page="categories">
|
|
||||||
<span class="nav-icon">◆</span> Categories
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#settings" class="nav-link" data-page="settings">
|
|
||||||
<span class="nav-icon">⚙</span> Settings
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#status" class="nav-link" data-page="status">
|
|
||||||
<span class="nav-icon">⏺</span> Status
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<span id="sidebar-username"></span>
|
|
||||||
<a href="#" id="sidebar-logout" class="nav-link sidebar-logout">
|
|
||||||
<span class="nav-icon">←</span> Sign Out
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<div id="page-tracker" class="page active"></div>
|
|
||||||
<div id="page-bills" class="page"></div>
|
|
||||||
<div id="page-categories" class="page"></div>
|
|
||||||
<div id="page-settings" class="page"></div>
|
|
||||||
<div id="page-status" class="page"></div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bill Modal -->
|
|
||||||
<div id="bill-modal" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="bill-modal-title">Add Bill</h2>
|
|
||||||
<button class="modal-close" id="bill-modal-close">×</button>
|
|
||||||
</div>
|
|
||||||
<form id="bill-form" class="modal-form">
|
|
||||||
<input type="hidden" id="bill-id">
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-name">Name *</label>
|
|
||||||
<input type="text" id="bill-name" placeholder="e.g. Electricity" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-category">Category</label>
|
|
||||||
<select id="bill-category">
|
|
||||||
<option value="">— none —</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-due-day">Due Day (1–31) *</label>
|
|
||||||
<input type="number" id="bill-due-day" min="1" max="31" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-expected">Expected Amount ($)</label>
|
|
||||||
<input type="number" id="bill-expected" min="0" step="0.01" placeholder="0.00">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-interest-rate">Interest rate (APR %)</label>
|
|
||||||
<input type="number" id="bill-interest-rate" min="0" max="100" step="0.01" placeholder="Optional">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-cycle">Billing Cycle</label>
|
|
||||||
<select id="bill-cycle">
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="quarterly">Quarterly</option>
|
|
||||||
<option value="annually">Annually</option>
|
|
||||||
<option value="irregular">Irregular</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="bill-autopay"> Autopay / Autodraft
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="bill-2fa"> Has 2FA
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-website">Website</label>
|
|
||||||
<input type="text" id="bill-website" placeholder="https://...">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-username">Username / Email</label>
|
|
||||||
<input type="text" id="bill-username">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bill-account-info">Account Info</label>
|
|
||||||
<input type="text" id="bill-account-info" placeholder="Last 4 digits, account #...">
|
|
||||||
</div>
|
|
||||||
<div class="form-group full-width">
|
|
||||||
<label for="bill-notes">Notes</label>
|
|
||||||
<textarea id="bill-notes" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="bill-modal-cancel" class="btn btn-ghost">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save Bill</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Modal -->
|
|
||||||
<div id="payment-modal" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box modal-box-sm">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="payment-modal-title">Edit Payment</h2>
|
|
||||||
<button class="modal-close" id="payment-modal-close">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="payment-form" class="modal-form">
|
|
||||||
<input type="hidden" id="payment-bill-id">
|
|
||||||
<input type="hidden" id="payment-id">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-amount">Amount ($) *</label>
|
|
||||||
<input type="number" id="payment-amount" min="0" step="0.01" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-date">Paid Date *</label>
|
|
||||||
<input type="date" id="payment-date" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-method">Method</label>
|
|
||||||
<select id="payment-method">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option value="bank">Bank Transfer</option>
|
|
||||||
<option value="card">Card</option>
|
|
||||||
<option value="autopay">Autopay</option>
|
|
||||||
<option value="check">Check</option>
|
|
||||||
<option value="cash">Cash</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="payment-notes">Notes</label>
|
|
||||||
<input type="text" id="payment-notes">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- UPDATED FOOTER -->
|
|
||||||
<div class="modal-footer">
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="payment-delete"
|
|
||||||
class="btn btn-danger"
|
|
||||||
style="margin-right:auto"
|
|
||||||
title="Removes this payment record and marks the bill as unpaid. The bill itself is NOT deleted.">
|
|
||||||
Remove Payment
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" id="payment-modal-cancel" class="btn btn-ghost">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Privacy notice — shown on first login -->
|
|
||||||
<div id="privacy-overlay" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box modal-box-sm" style="text-align:left">
|
|
||||||
<div style="font-size:22px;margin-bottom:12px">🔒</div>
|
|
||||||
<h2 style="font-size:17px;margin-bottom:12px">Your data is private</h2>
|
|
||||||
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:14px">
|
|
||||||
The <strong>admin account</strong> on this system can only:
|
|
||||||
</p>
|
|
||||||
<ul style="margin:0 0 16px 20px;color:var(--text-muted);font-size:14px;line-height:1.9">
|
|
||||||
<li>Create new user accounts</li>
|
|
||||||
<li>Reset your password if you're locked out</li>
|
|
||||||
</ul>
|
|
||||||
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:20px">
|
|
||||||
The admin <strong>cannot</strong> view, edit, or access your bills,
|
|
||||||
payments, or any financial data — by design.
|
|
||||||
</p>
|
|
||||||
<button class="btn btn-primary" id="privacy-ack-btn" style="width:100%">Got it, take me to my tracker</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Password change — shown when must_change_password is set -->
|
|
||||||
<div id="change-password-overlay" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-box modal-box-sm">
|
|
||||||
<h2 style="margin-bottom:6px">Change Your Password</h2>
|
|
||||||
<p style="color:var(--text-muted);font-size:13px;margin-bottom:18px">
|
|
||||||
Your password was reset by the admin. Please set a new one to continue.
|
|
||||||
</p>
|
|
||||||
<form id="change-password-form">
|
|
||||||
<div class="form-group" style="margin-bottom:12px">
|
|
||||||
<label for="cp-new">New Password</label>
|
|
||||||
<input type="password" id="cp-new" minlength="8" required placeholder="At least 8 characters">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom:16px">
|
|
||||||
<label for="cp-confirm">Confirm Password</label>
|
|
||||||
<input type="password" id="cp-confirm" minlength="8" required>
|
|
||||||
</div>
|
|
||||||
<div id="cp-error" style="color:var(--danger);font-size:13px;margin-bottom:10px;min-height:18px"></div>
|
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%">Set New Password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
|
||||||
<script src="/js/tracker.js"></script>
|
|
||||||
<script src="/js/bills.js"></script>
|
|
||||||
<script src="/js/categories.js"></script>
|
|
||||||
<script src="/js/settings.js"></script>
|
|
||||||
<script src="/js/status.js"></script>
|
|
||||||
<script src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/* Thin API client — all fetch calls go through here */
|
|
||||||
|
|
||||||
const API = {
|
|
||||||
async _fetch(method, path, body) {
|
|
||||||
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
|
|
||||||
// Add CSRF token header for state-changing methods
|
|
||||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
||||||
const name = 'bt_csrf_token';
|
|
||||||
const match = document.cookie.match(new RegExp(name + '=([^;]+)'));
|
|
||||||
if (match) opts.headers['x-csrf-token'] = match[1];
|
|
||||||
}
|
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
||||||
const res = await fetch('/api' + path, opts);
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
get: (path) => API._fetch('GET', path),
|
|
||||||
post: (path, body) => API._fetch('POST', path, body),
|
|
||||||
put: (path, body) => API._fetch('PUT', path, body),
|
|
||||||
delete: (path) => API._fetch('DELETE', path),
|
|
||||||
|
|
||||||
// Tracker
|
|
||||||
tracker: (year, month) => API.get(`/tracker?year=${year}&month=${month}`),
|
|
||||||
|
|
||||||
// Bills
|
|
||||||
bills: () => API.get('/bills'),
|
|
||||||
allBills: () => API.get('/bills?inactive=true'),
|
|
||||||
bill: (id) => API.get(`/bills/${id}`),
|
|
||||||
createBill: (data) => API.post('/bills', data),
|
|
||||||
updateBill: (id, d) => API.put(`/bills/${id}`, d),
|
|
||||||
deleteBill: (id) => API.delete(`/bills/${id}`),
|
|
||||||
|
|
||||||
// Payments
|
|
||||||
payments: (billId, y, m) => API.get(`/payments?bill_id=${billId}&year=${y}&month=${m}`),
|
|
||||||
quickPay: (data) => API.post('/payments/quick', data),
|
|
||||||
createPayment: (data) => API.post('/payments', data),
|
|
||||||
updatePayment: (id, data) => API.put(`/payments/${id}`, data),
|
|
||||||
deletePayment: (id) => API.delete(`/payments/${id}`),
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
categories: () => API.get('/categories'),
|
|
||||||
createCategory: (name) => API.post('/categories', { name }),
|
|
||||||
updateCategory: (id, n) => API.put(`/categories/${id}`, { name: n }),
|
|
||||||
deleteCategory: (id) => API.delete(`/categories/${id}`),
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
settings: () => API.get('/settings'),
|
|
||||||
saveSettings: (data) => API.put('/settings', data),
|
|
||||||
};
|
|
||||||
179
public/js/app.js
179
public/js/app.js
|
|
@ -1,179 +0,0 @@
|
||||||
/* ── App bootstrap & routing ── */
|
|
||||||
|
|
||||||
const PAGES = {
|
|
||||||
tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
|
|
||||||
bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
|
|
||||||
categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
|
|
||||||
settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
|
|
||||||
status: { container: 'page-status', init: (el) => StatusPage.init(el) },
|
|
||||||
};
|
|
||||||
|
|
||||||
let activePage = null;
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function boot() {
|
|
||||||
let res;
|
|
||||||
try { res = await fetch('/api/auth/me'); }
|
|
||||||
catch { location.href = '/login.html'; return; }
|
|
||||||
|
|
||||||
if (!res.ok) { location.href = '/login.html'; return; }
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
currentUser = data.user;
|
|
||||||
|
|
||||||
if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
|
|
||||||
|
|
||||||
document.getElementById('sidebar-username').textContent = currentUser.username;
|
|
||||||
|
|
||||||
if (data.single_user_mode) {
|
|
||||||
document.getElementById('sidebar-logout').style.display = 'none';
|
|
||||||
startApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
|
|
||||||
if (currentUser.first_login) { showPrivacyNotice(); return; }
|
|
||||||
|
|
||||||
startApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Password change overlay ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showChangePasswordOverlay() {
|
|
||||||
document.getElementById('change-password-overlay').classList.remove('hidden');
|
|
||||||
|
|
||||||
document.getElementById('change-password-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const np = document.getElementById('cp-new').value;
|
|
||||||
const cnf = document.getElementById('cp-confirm').value;
|
|
||||||
const err = document.getElementById('cp-error');
|
|
||||||
|
|
||||||
if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
|
|
||||||
if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
|
|
||||||
err.textContent = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/change-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ new_password: np }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
if (!res.ok) throw new Error(body.error);
|
|
||||||
document.getElementById('change-password-overlay').classList.add('hidden');
|
|
||||||
currentUser.must_change_password = false;
|
|
||||||
currentUser.first_login ? showPrivacyNotice() : startApp();
|
|
||||||
} catch (ex) {
|
|
||||||
document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Privacy notice overlay ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showPrivacyNotice() {
|
|
||||||
document.getElementById('privacy-overlay').classList.remove('hidden');
|
|
||||||
document.getElementById('privacy-ack-btn').onclick = async () => {
|
|
||||||
await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
|
|
||||||
document.getElementById('privacy-overlay').classList.add('hidden');
|
|
||||||
currentUser.first_login = false;
|
|
||||||
startApp();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main app ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startApp() {
|
|
||||||
document.getElementById('app').style.visibility = 'visible';
|
|
||||||
setupLogout();
|
|
||||||
setupNavLinks();
|
|
||||||
handleHash();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupLogout() {
|
|
||||||
document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
location.href = '/login.html';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupNavLinks() {
|
|
||||||
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
|
|
||||||
link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
|
|
||||||
});
|
|
||||||
window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHash() {
|
|
||||||
navigate(location.hash.replace('#', '') || 'tracker');
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(page) {
|
|
||||||
if (!PAGES[page]) page = 'tracker';
|
|
||||||
|
|
||||||
document.querySelectorAll('.nav-link[data-page]').forEach(a => {
|
|
||||||
a.classList.toggle('active', a.dataset.page === page);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
|
|
||||||
document.getElementById(PAGES[page].container).classList.add('active');
|
|
||||||
|
|
||||||
if (activePage !== page) {
|
|
||||||
activePage = page;
|
|
||||||
PAGES[page].init(document.getElementById(PAGES[page].container));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast system ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TOAST_ICONS = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✕',
|
|
||||||
warning: '⚠',
|
|
||||||
info: 'ℹ',
|
|
||||||
};
|
|
||||||
|
|
||||||
let toastContainer = null;
|
|
||||||
|
|
||||||
function getToastContainer() {
|
|
||||||
if (!toastContainer) {
|
|
||||||
toastContainer = document.createElement('div');
|
|
||||||
toastContainer.id = 'toast-container';
|
|
||||||
document.body.appendChild(toastContainer);
|
|
||||||
}
|
|
||||||
return toastContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg, type = 'info', duration = 3500) {
|
|
||||||
const container = getToastContainer();
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
|
|
||||||
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.innerHTML = `
|
|
||||||
<span class="toast-icon">${icon}</span>
|
|
||||||
<span class="toast-msg">${msg}</span>
|
|
||||||
<button class="toast-close" aria-label="Dismiss">×</button>
|
|
||||||
<div class="toast-bar"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.appendChild(toast);
|
|
||||||
// Trigger animation
|
|
||||||
requestAnimationFrame(() => toast.classList.add('toast-show'));
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
toast.classList.remove('toast-show');
|
|
||||||
toast.classList.add('toast-hide');
|
|
||||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const timer = setTimeout(dismiss, duration);
|
|
||||||
toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide app shell until auth check completes (prevents flash)
|
|
||||||
document.getElementById('app').style.visibility = 'hidden';
|
|
||||||
|
|
||||||
boot();
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
/* ── Bills management page ── */
|
|
||||||
|
|
||||||
const BillsPage = (() => {
|
|
||||||
let categories = [];
|
|
||||||
|
|
||||||
const CYCLE_LABELS = {
|
|
||||||
monthly: 'Monthly', quarterly: 'Quarterly',
|
|
||||||
annually: 'Annually', irregular: 'Irregular',
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmt(amount) {
|
|
||||||
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str || '')
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init(container) {
|
|
||||||
container.innerHTML = `<div class="loading">Loading...</div>`;
|
|
||||||
try {
|
|
||||||
[categories] = await Promise.all([API.categories()]);
|
|
||||||
render(container);
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(container) {
|
|
||||||
const bills = await API.allBills();
|
|
||||||
const active = bills.filter(b => b.active);
|
|
||||||
const inactive = bills.filter(b => !b.active);
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Bills</h1>
|
|
||||||
<button class="btn btn-primary" id="add-bill-btn">+ Add Bill</button>
|
|
||||||
</div>
|
|
||||||
<div class="bills-grid" id="bills-list">
|
|
||||||
${active.map(b => renderCard(b)).join('')}
|
|
||||||
${inactive.length ? `
|
|
||||||
<div style="margin-top:24px">
|
|
||||||
<div class="bucket-label" style="margin-bottom:12px">INACTIVE</div>
|
|
||||||
${inactive.map(b => renderCard(b, true)).join('')}
|
|
||||||
</div>` : ''}
|
|
||||||
${bills.length === 0 ? `<div class="empty-state"><p>No bills yet. Add your first bill!</p></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('add-bill-btn').onclick = () => openBillModal(null, () => render(container));
|
|
||||||
|
|
||||||
container.querySelectorAll('.btn-edit-bill').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
const bill = await API.bill(btn.dataset.id);
|
|
||||||
openBillModal(bill, () => render(container));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.btn-toggle-bill').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
const active = btn.dataset.active === '1';
|
|
||||||
if (!active || confirm('Deactivate this bill? It will be hidden from the tracker.')) {
|
|
||||||
await API.updateBill(btn.dataset.id, { active: active ? 0 : 1 });
|
|
||||||
render(container);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCard(bill, inactive = false) {
|
|
||||||
const catName = categories.find(c => c.id === bill.category_id)?.name || '';
|
|
||||||
return `
|
|
||||||
<div class="bill-card ${inactive ? 'inactive' : ''}">
|
|
||||||
<div class="bill-card-info">
|
|
||||||
<div class="bill-card-name">${escHtml(bill.name)}</div>
|
|
||||||
<div class="bill-card-meta">
|
|
||||||
Day ${bill.due_day}
|
|
||||||
${catName ? ` · ${escHtml(catName)}` : ''}
|
|
||||||
· ${CYCLE_LABELS[bill.billing_cycle] || bill.billing_cycle}
|
|
||||||
${bill.autopay_enabled ? ' · <span style="color:var(--warning)">Autopay</span>' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bill-card-amount">${fmt(bill.expected_amount)}</div>
|
|
||||||
<div class="bill-card-actions">
|
|
||||||
<button class="btn btn-ghost btn-sm btn-edit-bill" data-id="${bill.id}">Edit</button>
|
|
||||||
<button class="btn btn-ghost btn-sm btn-toggle-bill"
|
|
||||||
data-id="${bill.id}" data-active="${bill.active}">
|
|
||||||
${bill.active ? 'Deactivate' : 'Activate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBillModal(bill, onSave) {
|
|
||||||
const modal = document.getElementById('bill-modal');
|
|
||||||
const isNew = !bill;
|
|
||||||
|
|
||||||
document.getElementById('bill-modal-title').textContent = isNew ? 'Add Bill' : 'Edit Bill';
|
|
||||||
document.getElementById('bill-id').value = bill?.id || '';
|
|
||||||
document.getElementById('bill-name').value = bill?.name || '';
|
|
||||||
document.getElementById('bill-due-day').value = bill?.due_day || '';
|
|
||||||
document.getElementById('bill-expected').value = bill?.expected_amount || '';
|
|
||||||
document.getElementById('bill-interest-rate').value = bill?.interest_rate ?? '';
|
|
||||||
document.getElementById('bill-cycle').value = bill?.billing_cycle || 'monthly';
|
|
||||||
document.getElementById('bill-autopay').checked = !!bill?.autopay_enabled;
|
|
||||||
document.getElementById('bill-2fa').checked = !!bill?.has_2fa;
|
|
||||||
document.getElementById('bill-website').value = bill?.website || '';
|
|
||||||
document.getElementById('bill-username').value = bill?.username || '';
|
|
||||||
document.getElementById('bill-account-info').value = bill?.account_info || '';
|
|
||||||
document.getElementById('bill-notes').value = bill?.notes || '';
|
|
||||||
|
|
||||||
// Populate category select
|
|
||||||
const catSelect = document.getElementById('bill-category');
|
|
||||||
catSelect.innerHTML = '<option value="">— none —</option>' +
|
|
||||||
categories.map(c => `<option value="${c.id}" ${bill?.category_id == c.id ? 'selected' : ''}>${escHtml(c.name)}</option>`).join('');
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
const close = () => modal.classList.add('hidden');
|
|
||||||
document.getElementById('bill-modal-close').onclick = close;
|
|
||||||
document.getElementById('bill-modal-cancel').onclick = close;
|
|
||||||
modal.querySelector('.modal-overlay').onclick = close;
|
|
||||||
|
|
||||||
document.getElementById('bill-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
name: document.getElementById('bill-name').value.trim(),
|
|
||||||
category_id: document.getElementById('bill-category').value || null,
|
|
||||||
due_day: parseInt(document.getElementById('bill-due-day').value, 10),
|
|
||||||
expected_amount: parseFloat(document.getElementById('bill-expected').value) || 0,
|
|
||||||
interest_rate: document.getElementById('bill-interest-rate').value === '' ? null : parseFloat(document.getElementById('bill-interest-rate').value),
|
|
||||||
billing_cycle: document.getElementById('bill-cycle').value,
|
|
||||||
autopay_enabled: document.getElementById('bill-autopay').checked,
|
|
||||||
has_2fa: document.getElementById('bill-2fa').checked,
|
|
||||||
website: document.getElementById('bill-website').value || null,
|
|
||||||
username: document.getElementById('bill-username').value || null,
|
|
||||||
account_info: document.getElementById('bill-account-info').value || null,
|
|
||||||
notes: document.getElementById('bill-notes').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isNew) {
|
|
||||||
await API.createBill(data);
|
|
||||||
showToast('Bill added', 'success');
|
|
||||||
} else {
|
|
||||||
await API.updateBill(bill.id, data);
|
|
||||||
showToast('Bill updated', 'success');
|
|
||||||
}
|
|
||||||
close();
|
|
||||||
onSave();
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
/* ── Categories page ── */
|
|
||||||
|
|
||||||
const CategoriesPage = (() => {
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init(container) {
|
|
||||||
await render(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(container) {
|
|
||||||
let cats;
|
|
||||||
try {
|
|
||||||
cats = await API.categories();
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Categories</h1>
|
|
||||||
</div>
|
|
||||||
<form class="cat-add-form" id="cat-add-form">
|
|
||||||
<input type="text" id="cat-new-name" placeholder="New category name" style="max-width:280px">
|
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
|
||||||
</form>
|
|
||||||
<div class="cat-list" id="cat-list">
|
|
||||||
${cats.map(c => renderItem(c)).join('')}
|
|
||||||
${cats.length === 0 ? `<div class="empty-state"><p>No categories yet.</p></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('cat-add-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = document.getElementById('cat-new-name').value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
try {
|
|
||||||
await API.createCategory(name);
|
|
||||||
document.getElementById('cat-new-name').value = '';
|
|
||||||
showToast('Category added', 'success');
|
|
||||||
render(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.querySelectorAll('.btn-delete-cat').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
if (!confirm('Delete this category? Bills using it will be uncategorized.')) return;
|
|
||||||
try {
|
|
||||||
await API.deleteCategory(btn.dataset.id);
|
|
||||||
showToast('Category deleted', 'success');
|
|
||||||
render(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.cat-name-span').forEach(span => {
|
|
||||||
span.ondblclick = () => startRename(span, cats.find(c => c.id == span.dataset.id), container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderItem(cat) {
|
|
||||||
return `
|
|
||||||
<div class="cat-item" data-cat-id="${cat.id}">
|
|
||||||
<span class="cat-name cat-name-span" data-id="${cat.id}" title="Double-click to rename">
|
|
||||||
${escHtml(cat.name)}
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-ghost btn-sm btn-delete-cat" data-id="${cat.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRename(span, cat, container) {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.value = cat.name;
|
|
||||||
input.className = 'cat-name';
|
|
||||||
input.style.flex = '1';
|
|
||||||
span.replaceWith(input);
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
|
|
||||||
async function commit() {
|
|
||||||
const name = input.value.trim();
|
|
||||||
if (!name || name === cat.name) { render(container); return; }
|
|
||||||
try {
|
|
||||||
await API.updateCategory(cat.id, name);
|
|
||||||
showToast('Renamed', 'success');
|
|
||||||
render(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
render(container);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('blur', commit);
|
|
||||||
input.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') input.blur();
|
|
||||||
if (e.key === 'Escape') render(container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
/* ── Settings page ── */
|
|
||||||
|
|
||||||
const SettingsPage = (() => {
|
|
||||||
async function init(container) {
|
|
||||||
let settings, notifPrefs;
|
|
||||||
try {
|
|
||||||
[settings, notifPrefs] = await Promise.all([
|
|
||||||
API.settings(),
|
|
||||||
fetch('/api/notifications/me').then(r => r.ok ? r.json() : null),
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = `<div class="empty-state"><p>Failed to load settings.</p></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifSection = buildNotifSection(notifPrefs);
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Settings</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>General</h3>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-currency">Currency</label>
|
|
||||||
<select id="s-currency" style="max-width:120px">
|
|
||||||
<option value="USD" ${settings.currency === 'USD' ? 'selected' : ''}>USD $</option>
|
|
||||||
<option value="EUR" ${settings.currency === 'EUR' ? 'selected' : ''}>EUR €</option>
|
|
||||||
<option value="GBP" ${settings.currency === 'GBP' ? 'selected' : ''}>GBP £</option>
|
|
||||||
<option value="CAD" ${settings.currency === 'CAD' ? 'selected' : ''}>CAD $</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-date-format">Date Format</label>
|
|
||||||
<select id="s-date-format" style="max-width:160px">
|
|
||||||
<option value="MM/DD/YYYY" ${settings.date_format === 'MM/DD/YYYY' ? 'selected' : ''}>MM/DD/YYYY</option>
|
|
||||||
<option value="DD/MM/YYYY" ${settings.date_format === 'DD/MM/YYYY' ? 'selected' : ''}>DD/MM/YYYY</option>
|
|
||||||
<option value="YYYY-MM-DD" ${settings.date_format === 'YYYY-MM-DD' ? 'selected' : ''}>YYYY-MM-DD</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Billing Behavior</h3>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-grace">Grace Period (days)</label>
|
|
||||||
<input type="number" id="s-grace" min="0" max="30" value="${settings.grace_period_days || 5}" style="max-width:80px">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Backup</h3>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label>Auto Backup</label>
|
|
||||||
<label style="cursor:pointer">
|
|
||||||
<input type="checkbox" id="s-backup-enabled" ${settings.backup_enabled === 'true' ? 'checked' : ''}>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-backup-freq">Frequency (days)</label>
|
|
||||||
<input type="number" id="s-backup-freq" min="1" max="30" value="${settings.backup_frequency_days || 1}" style="max-width:80px">
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="s-backup-keep">Keep N Backups</label>
|
|
||||||
<input type="number" id="s-backup-keep" min="1" max="90" value="${settings.backup_keep_count || 14}" style="max-width:80px">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${notifSection}
|
|
||||||
|
|
||||||
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:8px">
|
|
||||||
<button class="btn btn-primary" id="save-settings-btn">Save Settings</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('save-settings-btn').onclick = async () => {
|
|
||||||
const data = {
|
|
||||||
currency: document.getElementById('s-currency').value,
|
|
||||||
date_format: document.getElementById('s-date-format').value,
|
|
||||||
grace_period_days: document.getElementById('s-grace').value,
|
|
||||||
backup_enabled: document.getElementById('s-backup-enabled').checked ? 'true' : 'false',
|
|
||||||
backup_frequency_days: document.getElementById('s-backup-freq').value,
|
|
||||||
backup_keep_count: document.getElementById('s-backup-keep').value,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await API.saveSettings(data);
|
|
||||||
showToast('Settings saved', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Notification save (only wired if section exists)
|
|
||||||
const notifForm = document.getElementById('notif-user-form');
|
|
||||||
if (notifForm) {
|
|
||||||
notifForm.onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = notifForm.querySelector('[type="submit"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/me', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
notification_email: document.getElementById('n-email').value.trim(),
|
|
||||||
notifications_enabled:document.getElementById('n-enabled').checked,
|
|
||||||
notify_3d: document.getElementById('n-3d').checked,
|
|
||||||
notify_1d: document.getElementById('n-1d').checked,
|
|
||||||
notify_due: document.getElementById('n-due').checked,
|
|
||||||
notify_overdue: document.getElementById('n-overdue').checked,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
|
||||||
showToast('Notification preferences saved', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle field visibility based on enabled checkbox
|
|
||||||
const toggle = () => {
|
|
||||||
const on = document.getElementById('n-enabled').checked;
|
|
||||||
document.getElementById('n-options').style.opacity = on ? '1' : '.4';
|
|
||||||
document.getElementById('n-options').style.pointerEvents = on ? '' : 'none';
|
|
||||||
};
|
|
||||||
document.getElementById('n-enabled').addEventListener('change', toggle);
|
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNotifSection(p) {
|
|
||||||
if (!p) return ''; // API call failed, skip silently
|
|
||||||
|
|
||||||
if (!p.smtp_enabled) {
|
|
||||||
return `
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p style="color:var(--text-muted);font-size:13px;">
|
|
||||||
Email notifications have not been configured by the admin.
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!p.allow_user_config) {
|
|
||||||
return `
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p style="color:var(--text-muted);font-size:13px;">
|
|
||||||
Email notifications are enabled and managed by the admin.
|
|
||||||
Your bills will generate reminders automatically.
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full user-configurable notification section
|
|
||||||
const chk = (id, label, val) =>
|
|
||||||
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:normal;color:var(--text)">
|
|
||||||
<input type="checkbox" id="${id}" ${val ? 'checked' : ''}> ${label}
|
|
||||||
</label>`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<form id="notif-user-form">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label>Enable Notifications</label>
|
|
||||||
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-weight:normal;color:var(--text)">
|
|
||||||
<input type="checkbox" id="n-enabled" ${p.notifications_enabled ? 'checked' : ''}>
|
|
||||||
Send me email reminders
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="n-options">
|
|
||||||
<div class="settings-row" style="margin-top:6px">
|
|
||||||
<label for="n-email">Notification Email</label>
|
|
||||||
<input type="email" id="n-email" value="${escHtml(p.notification_email)}"
|
|
||||||
placeholder="your@email.com" style="max-width:280px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-row" style="align-items:flex-start">
|
|
||||||
<label style="padding-top:2px">Remind me</label>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
|
||||||
${chk('n-3d', '3 days before due', p.notify_3d)}
|
|
||||||
${chk('n-1d', '1 day before due', p.notify_1d)}
|
|
||||||
${chk('n-due', 'On the day it\'s due', p.notify_due)}
|
|
||||||
${chk('n-overdue', 'Daily while overdue', p.notify_overdue)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:12px">
|
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Save Notifications</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/* ── Status page ── */
|
|
||||||
|
|
||||||
const StatusPage = (() => {
|
|
||||||
async function init(container) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Server Status</h1>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="status-refresh">↻ Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div id="status-body"><div class="loading">Loading...</div></div>
|
|
||||||
`;
|
|
||||||
document.getElementById('status-refresh').onclick = () => load(container);
|
|
||||||
load(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(container) {
|
|
||||||
const body = document.getElementById('status-body');
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/status');
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const d = await res.json();
|
|
||||||
body.innerHTML = render(d);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = `<div class="empty-state"><p>Failed to load status: ${e.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtUptime(seconds) {
|
|
||||||
const d = Math.floor(seconds / 86400);
|
|
||||||
const h = Math.floor((seconds % 86400) / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
|
||||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
|
||||||
if (m > 0) return `${m}m ${s}s`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(label, value, note = '') {
|
|
||||||
return `
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">${label}</span>
|
|
||||||
<span class="stat-value">${value}${note ? `<span class="stat-note">${note}</span>` : ''}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(d) {
|
|
||||||
const dbOk = d.database.status === 'connected';
|
|
||||||
return `
|
|
||||||
<div class="status-grid">
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Application</span>
|
|
||||||
<span class="status-dot dot-green" title="Online"></span>
|
|
||||||
</div>
|
|
||||||
${row('Version', `v${d.app.version}`)}
|
|
||||||
${row('Environment', d.app.environment)}
|
|
||||||
${row('Uptime', fmtUptime(d.app.uptime_seconds))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Runtime</span>
|
|
||||||
</div>
|
|
||||||
${row('Node.js', d.runtime.node_version)}
|
|
||||||
${row('Platform', `${d.runtime.platform} / ${d.runtime.arch}`)}
|
|
||||||
${row('Memory', `${d.runtime.memory_mb} MB`)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Database</span>
|
|
||||||
<span class="status-dot ${dbOk ? 'dot-green' : 'dot-red'}" title="${d.database.status}"></span>
|
|
||||||
</div>
|
|
||||||
${row('Status', dbOk ? 'Connected' : 'Error')}
|
|
||||||
${row('Size', fmtBytes(d.database.size_bytes))}
|
|
||||||
${row('File', `<code style="font-size:11px">${d.database.path}</code>`)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-card-header">
|
|
||||||
<span class="status-card-title">Statistics</span>
|
|
||||||
</div>
|
|
||||||
${row('Active Bills', d.stats.active_bills)}
|
|
||||||
${row('Total Payments', d.stats.total_payments)}
|
|
||||||
${row('Users', d.stats.users)}
|
|
||||||
${row('Active Sessions', d.stats.active_sessions)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,424 +0,0 @@
|
||||||
/* ── Tracker page ── */
|
|
||||||
|
|
||||||
const TrackerPage = (() => {
|
|
||||||
let currentYear, currentMonth;
|
|
||||||
let trackerData = null;
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
'January','February','March','April','May','June',
|
|
||||||
'July','August','September','October','November','December',
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_META = {
|
|
||||||
paid: { label: 'Paid', cls: 'badge-paid' },
|
|
||||||
upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
|
|
||||||
due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
|
|
||||||
late: { label: 'Late', cls: 'badge-late' },
|
|
||||||
missed: { label: 'Missed', cls: 'badge-missed' },
|
|
||||||
autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmt(amount) {
|
|
||||||
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const [y, m, d] = dateStr.split('-');
|
|
||||||
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayStr() {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(container) {
|
|
||||||
const now = new Date();
|
|
||||||
currentYear = now.getFullYear();
|
|
||||||
currentMonth = now.getMonth() + 1;
|
|
||||||
render(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(container) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<h1 class="page-title">Tracker</h1>
|
|
||||||
<div class="month-nav">
|
|
||||||
<button class="btn btn-ghost btn-sm" id="prev-month">←</button>
|
|
||||||
<span class="month-label" id="month-label"></span>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="next-month">→</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="today-btn">Today</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-bar" id="summary-bar"></div>
|
|
||||||
<div id="tracker-body"><div class="loading">Loading...</div></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('month-label').textContent =
|
|
||||||
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
|
|
||||||
|
|
||||||
document.getElementById('prev-month').onclick = () => navigate(-1, container);
|
|
||||||
document.getElementById('next-month').onclick = () => navigate(1, container);
|
|
||||||
document.getElementById('today-btn').onclick = () => {
|
|
||||||
const now = new Date();
|
|
||||||
currentYear = now.getFullYear();
|
|
||||||
currentMonth = now.getMonth() + 1;
|
|
||||||
loadData(container);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(delta, container) {
|
|
||||||
currentMonth += delta;
|
|
||||||
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
|
|
||||||
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
|
|
||||||
document.getElementById('month-label').textContent =
|
|
||||||
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
|
|
||||||
loadData(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData(container) {
|
|
||||||
document.getElementById('tracker-body').innerHTML = '<div class="loading">Loading...</div>';
|
|
||||||
try {
|
|
||||||
trackerData = await API.tracker(currentYear, currentMonth);
|
|
||||||
renderSummary(trackerData.summary);
|
|
||||||
renderRows(trackerData.rows, container);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('tracker-body').innerHTML =
|
|
||||||
`<div class="empty-state"><p>Failed to load tracker: ${e.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummary(s) {
|
|
||||||
document.getElementById('summary-bar').innerHTML = `
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="label">Total Expected</div>
|
|
||||||
<div class="value">${fmt(s.total_expected)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card success">
|
|
||||||
<div class="label">Total Paid</div>
|
|
||||||
<div class="value">${fmt(s.total_paid)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="label">Remaining</div>
|
|
||||||
<div class="value">${fmt(s.remaining)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card ${s.overdue > 0 ? 'danger' : ''}">
|
|
||||||
<div class="label">Overdue</div>
|
|
||||||
<div class="value">${fmt(s.overdue)}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRows(rows, container) {
|
|
||||||
const body = document.getElementById('tracker-body');
|
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
body.innerHTML = `<div class="empty-state">
|
|
||||||
<p>No bills this month. <a href="#bills" class="btn-link">Add a bill</a></p>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = rows.filter(r => r.bucket === '1st');
|
|
||||||
const second = rows.filter(r => r.bucket === '15th');
|
|
||||||
|
|
||||||
body.innerHTML = '';
|
|
||||||
if (first.length) body.appendChild(renderBucket('1st–14th', first));
|
|
||||||
if (second.length) body.appendChild(renderBucket('15th–31st', second));
|
|
||||||
|
|
||||||
// Attach event listeners after render
|
|
||||||
attachTableListeners(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBucket(label, rows) {
|
|
||||||
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
|
|
||||||
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
|
|
||||||
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'bucket-section';
|
|
||||||
section.innerHTML = `
|
|
||||||
<div class="bucket-header">
|
|
||||||
<span class="bucket-label">${label}</span>
|
|
||||||
<span class="bucket-totals">${fmt(totalPaid)} / ${fmt(totalExpected)}</span>
|
|
||||||
</div>
|
|
||||||
<table class="tracker-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bill</th>
|
|
||||||
<th>Due</th>
|
|
||||||
<th>Expected</th>
|
|
||||||
<th>Amount Paid</th>
|
|
||||||
<th>Paid Date</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${rows.map(renderRow).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRow(row) {
|
|
||||||
const meta = STATUS_META[row.status] || STATUS_META.upcoming;
|
|
||||||
const rowCls = `row-${row.status}`;
|
|
||||||
|
|
||||||
const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
|
|
||||||
const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
|
|
||||||
const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
|
|
||||||
const isPaid = row.status === 'paid' || row.status === 'autodraft';
|
|
||||||
|
|
||||||
const autopayDot = row.autopay_enabled
|
|
||||||
? `<span class="autopay-dot" title="Autopay"></span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr class="${rowCls}" data-bill-id="${row.id}">
|
|
||||||
<td>
|
|
||||||
<div class="td-inner bill-name-cell">
|
|
||||||
${autopayDot}
|
|
||||||
<div>
|
|
||||||
<div>${escHtml(row.name)}</div>
|
|
||||||
${row.category_name ? `<div class="bill-category">${escHtml(row.category_name)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">${fmtDate(row.due_date)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner amount-expected">${fmt(row.expected_amount)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">
|
|
||||||
<span class="editable-cell amount-cell ${paidAmt ? '' : 'empty'} ${mismatch ? 'amount-mismatch' : ''}"
|
|
||||||
data-bill-id="${row.id}" data-field="amount"
|
|
||||||
title="Click to edit payment amount">
|
|
||||||
${paidAmt || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">
|
|
||||||
<span class="editable-cell date-cell ${paidDate ? '' : 'empty'}"
|
|
||||||
data-bill-id="${row.id}" data-field="date"
|
|
||||||
title="Click to edit paid date">
|
|
||||||
${paidDate || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="td-inner">
|
|
||||||
<span class="badge ${meta.cls}">${meta.label}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="action-cell">
|
|
||||||
${!isPaid ? `
|
|
||||||
<div class="quick-pay-group">
|
|
||||||
<input type="number" class="quick-pay-amount" min="0" step="0.01"
|
|
||||||
value="${row.expected_amount}"
|
|
||||||
data-bill-id="${row.id}"
|
|
||||||
title="Payment amount">
|
|
||||||
<button class="btn-pay btn-quick-pay" data-bill-id="${row.id}"
|
|
||||||
title="Mark paid today">Pay</button>
|
|
||||||
</div>` : ''}
|
|
||||||
${row.payments && row.payments.length > 0
|
|
||||||
? `<button class="btn-icon btn-edit-payment"
|
|
||||||
data-bill-id="${row.id}"
|
|
||||||
data-payment='${JSON.stringify(row.payments[0])}'
|
|
||||||
title="Edit payment">✎</button>`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachTableListeners(container) {
|
|
||||||
// Quick pay buttons — read amount from the sibling input
|
|
||||||
container.querySelectorAll('.btn-quick-pay').forEach(btn => {
|
|
||||||
btn.onclick = async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const billId = btn.dataset.billId;
|
|
||||||
const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
|
|
||||||
const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
|
|
||||||
if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
|
|
||||||
showToast('Marked as paid', 'success');
|
|
||||||
loadData(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit payment buttons
|
|
||||||
container.querySelectorAll('.btn-edit-payment').forEach(btn => {
|
|
||||||
btn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const payment = JSON.parse(btn.dataset.payment);
|
|
||||||
openPaymentModal(payment, () => loadData(container));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inline editable amount cells
|
|
||||||
container.querySelectorAll('.amount-cell').forEach(cell => {
|
|
||||||
cell.onclick = () => startInlineEdit(cell, 'number', container);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inline editable date cells
|
|
||||||
container.querySelectorAll('.date-cell').forEach(cell => {
|
|
||||||
cell.onclick = () => startInlineEdit(cell, 'date', container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startInlineEdit(cell, type, container) {
|
|
||||||
if (cell.querySelector('input')) return; // already editing
|
|
||||||
|
|
||||||
const billId = cell.dataset.billId;
|
|
||||||
const field = cell.dataset.field;
|
|
||||||
const row = trackerData?.rows?.find(r => r.id == billId);
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
let currentVal = '';
|
|
||||||
if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
|
|
||||||
if (field === 'date') currentVal = row.last_paid_date || '';
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = type === 'date' ? 'date' : 'number';
|
|
||||||
if (type === 'number') { input.step = '0.01'; input.min = '0'; }
|
|
||||||
input.value = currentVal;
|
|
||||||
input.style.cssText = 'width:100%;min-width:80px;';
|
|
||||||
|
|
||||||
const origText = cell.textContent.trim();
|
|
||||||
cell.textContent = '';
|
|
||||||
cell.appendChild(input);
|
|
||||||
cell.classList.remove('empty');
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
|
|
||||||
async function commit() {
|
|
||||||
const val = input.value.trim();
|
|
||||||
if (!val) { cell.textContent = origText || '—'; cell.classList.add('empty'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (row.payments && row.payments.length > 0) {
|
|
||||||
const p = row.payments[0];
|
|
||||||
const update = {};
|
|
||||||
if (field === 'amount') update.amount = parseFloat(val);
|
|
||||||
if (field === 'date') update.paid_date = val;
|
|
||||||
await API.updatePayment(p.id, update);
|
|
||||||
} else {
|
|
||||||
// Create new payment
|
|
||||||
const paidDate = field === 'date' ? val : todayStr();
|
|
||||||
const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
|
|
||||||
await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
|
|
||||||
}
|
|
||||||
showToast('Saved', 'success');
|
|
||||||
loadData(container);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
cell.textContent = origText || '—';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('blur', commit);
|
|
||||||
input.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') input.blur();
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
cell.textContent = origText || '—';
|
|
||||||
if (!origText) cell.classList.add('empty');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPaymentModal(payment, onSave) {
|
|
||||||
const modal = document.getElementById('payment-modal');
|
|
||||||
|
|
||||||
document.getElementById('payment-bill-id').value = payment.bill_id;
|
|
||||||
document.getElementById('payment-id').value = payment.id;
|
|
||||||
document.getElementById('payment-amount').value = payment.amount;
|
|
||||||
document.getElementById('payment-date').value = payment.paid_date;
|
|
||||||
document.getElementById('payment-method').value = payment.method || '';
|
|
||||||
document.getElementById('payment-notes').value = payment.notes || '';
|
|
||||||
|
|
||||||
document.getElementById('payment-modal-title').textContent = 'Edit Payment';
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
const close = () => modal.classList.add('hidden');
|
|
||||||
|
|
||||||
document.getElementById('payment-modal-close').onclick = close;
|
|
||||||
document.getElementById('payment-modal-cancel').onclick = close;
|
|
||||||
modal.querySelector('.modal-overlay').onclick = close;
|
|
||||||
|
|
||||||
// ✅ UPDATED DELETE LOGIC WITH CLEAR INTENT
|
|
||||||
document.getElementById('payment-delete').onclick = async () => {
|
|
||||||
const confirmDelete = confirm(
|
|
||||||
'Remove this payment?\n\n' +
|
|
||||||
'- The BILL will NOT be deleted\n' +
|
|
||||||
'- This will remove the payment record\n' +
|
|
||||||
'- The bill will become UNPAID\n\n' +
|
|
||||||
'Continue?'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmDelete) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await API.deletePayment(payment.id);
|
|
||||||
|
|
||||||
close();
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
'Payment removed. Bill is now marked as unpaid.',
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
onSave(); // refresh tracker
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Error: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// normal save logic untouched
|
|
||||||
document.getElementById('payment-form').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
amount: parseFloat(document.getElementById('payment-amount').value),
|
|
||||||
paid_date: document.getElementById('payment-date').value,
|
|
||||||
method: document.getElementById('payment-method').value || null,
|
|
||||||
notes: document.getElementById('payment-notes').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await API.updatePayment(payment.id, data);
|
|
||||||
|
|
||||||
close();
|
|
||||||
showToast('Payment saved', 'success');
|
|
||||||
onSave();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Error: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str || '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="refresh" content="0; url=/login">
|
|
||||||
<title>BillTracker — Sign In</title>
|
|
||||||
<script>
|
|
||||||
window.location.replace('/login');
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p><a href="/login">Continue to BillTracker sign in</a></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -152,8 +152,10 @@ app.all('/api/*', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Legacy UI ("Remember When" mode) ─────────────────────────────────────────
|
// ── Retired legacy UI ────────────────────────────────────────────────────────
|
||||||
app.use('/legacy', express.static(path.join(__dirname, 'legacy')));
|
app.all(['/legacy', '/legacy/*'], (req, res) => {
|
||||||
|
res.status(410).send('The legacy UI has been retired.');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Modern UI (Vite build) ────────────────────────────────────────────────────
|
// ── Modern UI (Vite build) ────────────────────────────────────────────────────
|
||||||
app.get('/login.html', (req, res) => res.redirect(302, '/login'));
|
app.get('/login.html', (req, res) => res.redirect(302, '/login'));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue