Compare commits
17 Commits
3d2a615fa8
...
43bd58910a
| Author | SHA1 | Date |
|---|---|---|
|
|
43bd58910a | |
|
|
5c828a1aa3 | |
|
|
e8d592d326 | |
|
|
6be7d8956e | |
|
|
f7c78a4a31 | |
|
|
eb5aa72d56 | |
|
|
ea651dd7c9 | |
|
|
ee3fcf8bf8 | |
|
|
59c45d192d | |
|
|
0c7824c52f | |
|
|
cb0e119941 | |
|
|
bb5afcafb2 | |
|
|
eb908ce934 | |
|
|
25c768d013 | |
|
|
c59ad6cb70 | |
|
|
678ff5eb19 | |
|
|
7cbdefbcfe |
53
HISTORY.md
|
|
@ -1,7 +1,59 @@
|
|||
# Bill Tracker — Changelog
|
||||
|
||||
## v0.18.1
|
||||
|
||||
### Changed
|
||||
- Updated Admin authentik/OIDC issuer help text to show the authentik discovery URL example and clarify that issuer base or full discovery URL can be used.
|
||||
- Updated the default category seed list to the top 10 common bill categories, safely filling missing user-scoped defaults without renaming or deleting existing categories.
|
||||
- Categories now return user-scoped active/inactive bill counts, payment counts, bill name previews, and compact bill detail data.
|
||||
- Categories page now shows compact stat chips for active bills, inactive bills, and payments with a subtle legend.
|
||||
- Removed the category-level total paid chip from Categories while keeping bill-level paid totals in expanded details.
|
||||
- Category rows now expand to show bills in that category, with hover/tap summaries for chips and bill names.
|
||||
- Improved Categories page mobile and tablet layout so chips wrap cleanly and expanded bill details stay readable without page-level horizontal scrolling.
|
||||
- Added a Summary page for monthly planning with income, expenses, paid expense count, result/savings, and browser Print / PDF output.
|
||||
- Added minimal user-scoped monthly income support for the Summary page.
|
||||
- Added a user-scoped `GET /api/summary` endpoint and income save endpoint using existing bills, payments, and monthly bill state data.
|
||||
- Summary includes a simple income, expenses, and savings chart without adding a new chart library.
|
||||
- Cleaned up the Summary page layout with a centered planner view, display-first Monthly Plan card, compact income editing, cleaner expense rows, and a calmer chart card.
|
||||
- Summary Print / PDF behavior remains browser-based and no backend/payment behavior was changed.
|
||||
- Added a Calendar page with a month grid for user-owned bills and payments, compact day indicators, a legend, monthly progress summary, and day detail dialog.
|
||||
- Added a user-scoped `GET /api/calendar` endpoint for one-month calendar data using existing bills, payments, categories, and monthly bill state records without schema changes.
|
||||
- Calendar status and totals respect monthly actual amount overrides, skipped bills, existing due-day clamping, and existing tracker-style late/missed status behavior.
|
||||
- Added Calendar to the top navigation after Tracker while preserving the existing desktop and mobile nav behavior.
|
||||
- Improved mobile and tablet responsive rendering across the top navigation, page headers, dialogs, dense tables, Tracker, Bills, Categories, Settings, Status, Admin, Analytics, and Login views.
|
||||
- Preserved the current desktop layout by keeping existing desktop-oriented layouts at `lg` and above while adding mobile/tablet stacking, scrolling, and tap-friendly controls below that breakpoint.
|
||||
- Tablet navigation now uses the compact menu to avoid horizontal overflow; user menu, theme toggle, and admin-only navigation remain reachable.
|
||||
- Dialogs and destructive confirmations now respect mobile viewport width/height and scroll internally when content is long.
|
||||
- Dense Tracker, Bills, Admin, Analytics, and import/history style tables use horizontal scrolling or mobile stacking so actions remain reachable on smaller screens.
|
||||
- Tracker and Bills now use stacked mobile/tablet bill rows below `lg`, reducing sideways scrolling for normal bill review, quick payment, and bill actions while preserving the desktop table layouts.
|
||||
- Tracker mobile notes stay contained in each bill row, so long notes can truncate or scroll locally without forcing the whole bill list sideways.
|
||||
|
||||
### Notes
|
||||
- No auth behavior, tracker/payment/bill business logic, admin permissions, or desktop redesign changes were made.
|
||||
- No Tracker, Bills, payment, analytics, calendar, auth, or admin behavior was changed for the Categories page updates.
|
||||
- No Tracker, Bills, payment, Calendar, Analytics, auth, or admin behavior was changed for the Summary page updates.
|
||||
|
||||
## v0.18
|
||||
|
||||
### Branding
|
||||
- Replaced the top-navbar dollar-sign placeholder and duplicate text/version brand stack with the selected `/img/logo.png` BillTracker logo.
|
||||
- The logo now serves as the BillTracker brand in the top navigation while preserving the existing navbar height and route behavior.
|
||||
- Login now uses the BillTracker logo, shows linked build/version information near the login actions, and uses the authentik icon for OIDC login.
|
||||
- Admin Authentication Methods now uses subtle authentik branding in the OIDC toggle/configuration/test-login controls.
|
||||
- Cropped transparent padding from the BillTracker logo asset so it renders larger and more readably in the unchanged-height navbar.
|
||||
- Promoted the transparent `logo_cut.png` artwork to the served `/img/logo.png` asset and enlarged the login-page logo while keeping the login card layout compact.
|
||||
- Login logo sizing now follows the login form width so the brand grows and shrinks with the sign-in column instead of rendering too small.
|
||||
- Legacy `/login.html` now redirects to the modern React `/login` screen so the old static login page is no longer served by stale links.
|
||||
- Vite now copies only modern React public assets from `client/public`, preventing legacy `public/*.html`, CSS, and JS files from being emitted into `dist`.
|
||||
- No backend, auth, tracker, bills, categories, settings, status, admin, or navigation-link behavior was changed.
|
||||
|
||||
### Analytics
|
||||
- Added a user-scoped Analytics API at `GET /api/analytics/summary` using existing bills, payments, categories, and monthly bill state data without schema changes.
|
||||
- Added an Analytics page with date range controls, category and bill filters, inactive/skipped toggles, chart visibility toggles, and a line/area trend option.
|
||||
- Added monthly spending trend, expected vs actual spend, category spending donut, and pay-on-time heatmap views.
|
||||
- Added print and browser save-as-PDF report output with print CSS that hides navigation, controls, and interactive actions.
|
||||
- Analytics queries are scoped to the signed-in user and do not accept or expose cross-user aggregation.
|
||||
|
||||
### Security
|
||||
- **OIDC ID token signature verification** now uses `openid-client@5` for full cryptographic validation via JWKS: signature, issuer, audience, expiry, nonce, and `sub` presence — tokens without a valid signature are rejected
|
||||
- **OIDC client cache** invalidation path added; cache is keyed by issuer/client/redirect so Admin panel credential changes pick up a fresh client
|
||||
|
|
@ -19,6 +71,7 @@
|
|||
- **authentik client auth method**: Admin OIDC settings now include an advanced `client_secret_basic` / `client_secret_post` token endpoint authentication method selector. The default remains `client_secret_basic`, matching the previous `openid-client` behavior.
|
||||
- **Admin user role management**: Admin Users table now lets an admin promote another user to `admin` or demote an admin back to `user`, with protections against changing your own role or removing the last admin account.
|
||||
- **Single-user mode recovery**: User Settings now shows a Login Mode section while single-user mode is active, allowing the default user to restore multi-user login without needing access to Admin routes.
|
||||
- **Admin navigation parity**: Admin users now keep the normal app navigation and get an Admin link after Status; `/admin` uses the same top nav so admins can return to Tracker/Bills/Categories/Profile/Settings/Status without typing a URL. Backend `/admin` protection remains unchanged.
|
||||
- **Admin-controlled auth method toggles** in Admin panel (Authentication Methods card):
|
||||
- `local_login_enabled` — enable/disable local username/password login (default: enabled)
|
||||
- `oidc_login_enabled` — enable/disable OIDC/authentik login (default: disabled)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
# BillTracker
|
||||
<p align="center">
|
||||
<img src="docs/images/logo_cut.png" alt="Tracker logo">
|
||||
|
||||
</p>
|
||||
|
||||
BillTracker is a self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history. It runs as a Node/Express server with a React frontend and stores data in SQLite. This product was produced with the assistance of AI.
|
||||
|
||||
<p align="center">
|
||||
Demo Server
|
||||
https://t1.scheller.ltd/
|
||||
<br>
|
||||
Username: guest
|
||||
<br>
|
||||
Password: guest123
|
||||
</p>
|
||||
|
||||
## Screenshots
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## What Is BillTracker?
|
||||
|
||||
|
||||
BillTracker helps a household or small self-hosted setup keep bill data in one place:
|
||||
|
||||
- recurring bill records with due day, expected amount, category, notes, autopay details, and optional APR
|
||||
- monthly tracker with payments, skipped bills, actual monthly amounts, and notes
|
||||
- calendar view for due dates and payments
|
||||
- analytics for monthly spending, expected vs actual totals, category spend, and payment history
|
||||
- categories, profile, display name, notification preferences, password changes, and data tools
|
||||
- admin user management, authentication settings, backups, cleanup, and status checks
|
||||
|
||||
## Features
|
||||
|
||||
- Tracker for month-by-month bill status, payment entry, notes, skipped bills, overdue totals, and month navigation
|
||||
- Bills page for creating, editing, deactivating, reactivating, deleting, and controlling inactive bill history
|
||||
- Calendar page with a monthly grid, bill due dates, payments, and progress summary
|
||||
- Analytics page with date range, category/bill filters, charts, heatmap, and print output
|
||||
- User-owned categories
|
||||
- Settings for theme, currency, date format, and grace period
|
||||
- Profile page with display name, notification preferences, password change, imports, exports, and import history
|
||||
- User exports to Excel workbook or BillTracker user SQLite export
|
||||
- XLSX spreadsheet import with preview and import decisions
|
||||
- User SQLite import from exports created by this app
|
||||
- Admin users, role management, password resets, full database backups/restores, scheduled backups, cleanup, auth settings, and status page
|
||||
- Local username/password login and optional authentik/OIDC login
|
||||
|
||||
## Quick Start
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Run the API and Vite frontend for development:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build the frontend:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start the production server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The production server serves `dist/` and listens on `PORT`, defaulting to `3000`.
|
||||
|
||||
Useful scripts present in this repo:
|
||||
|
||||
```bash
|
||||
npm run dev:api
|
||||
npm run dev:ui
|
||||
npm run build
|
||||
npm start
|
||||
node scripts/test-import.js
|
||||
node scripts/test-oidc-smoke.js
|
||||
node scripts/test-cookie-options.js
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Docker files are included. The compose file runs the published image on host port `3030` and stores app data under `/data` in the container.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
On first start without an existing database, create the admin account with:
|
||||
|
||||
```bash
|
||||
INIT_ADMIN_USER=admin
|
||||
INIT_ADMIN_PASS=change-this-password
|
||||
```
|
||||
|
||||
Remove or change those first-run values after the initial admin account exists.
|
||||
|
||||
## Configuration
|
||||
|
||||
Most app settings are configured in the web UI. User-facing settings live under Settings/Profile. Server-wide settings such as users, backups, cleanup, and authentication methods live in Admin.
|
||||
|
||||
Real environment variables used by the app:
|
||||
|
||||
```bash
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
DB_PATH=/path/to/bills.db
|
||||
BACKUP_PATH=/path/to/backups
|
||||
INIT_ADMIN_USER=admin
|
||||
INIT_ADMIN_PASS=change-this-password
|
||||
HTTPS=true
|
||||
COOKIE_SECURE=true
|
||||
CORS_ORIGIN=https://bills.example.com
|
||||
```
|
||||
|
||||
OIDC environment fallback variables are supported when the matching Admin database setting is blank:
|
||||
|
||||
```bash
|
||||
OIDC_PROVIDER_NAME=authentik
|
||||
OIDC_ISSUER_URL=https://yourURL.com/application/o/bills/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=<client-id>
|
||||
OIDC_CLIENT_SECRET=<client-secret>
|
||||
OIDC_TOKEN_AUTH_METHOD=client_secret_basic
|
||||
OIDC_REDIRECT_URI=https://bills.example.com/api/auth/oidc/callback
|
||||
OIDC_SCOPES="openid email profile groups"
|
||||
OIDC_ADMIN_GROUP=bill-tracker-admins
|
||||
OIDC_AUTO_PROVISION=true
|
||||
```
|
||||
|
||||
Database-backed Admin settings take precedence over environment fallback values.
|
||||
|
||||
## Authentication
|
||||
|
||||
BillTracker supports local username/password login by default. Admins can create users, reset user passwords, promote/demote users, and configure login methods.
|
||||
|
||||
Optional authentik/OIDC login can be enabled in Admin. OIDC uses authorization code flow with PKCE, state and nonce validation, and `openid-client` token validation. OIDC users can be auto-provisioned when enabled.
|
||||
|
||||
Admin role is never granted by default through OIDC. Set an authentik admin group in BillTracker; only users whose OIDC `groups` claim includes that configured group become app admins.
|
||||
|
||||
BillTracker includes lockout checks so local login cannot be disabled unless OIDC is configured, enabled, and mapped to an admin group.
|
||||
|
||||
## authentik Setup
|
||||
|
||||
In authentik, create an OAuth2/OpenID provider/application for BillTracker:
|
||||
|
||||
- Client type: confidential
|
||||
- Redirect URI: `https://bills.example.com/api/auth/oidc/callback`
|
||||
- Scopes: `openid email profile groups`
|
||||
- Groups claim: make sure authentik sends `groups`
|
||||
- Admin group: create or choose the authentik group that should become BillTracker admins
|
||||
|
||||
In BillTracker, go to Admin -> Authentication Methods and set:
|
||||
|
||||
- Provider name: `authentik`
|
||||
- Issuer/discovery URL: `https://yourURL.com/application/o/bills/.well-known/openid-configuration`
|
||||
- Client ID and client secret from authentik
|
||||
- Redirect URI matching the authentik allowed redirect URI
|
||||
- Scopes: `openid email profile groups`
|
||||
- Admin group: the exact authentik group name for BillTracker admins
|
||||
- Auto-provision users: enabled if you want valid authentik users created on first login
|
||||
|
||||
The backend accepts either the provider issuer base URL or the full discovery URL. For authentik, the full discovery URL example is:
|
||||
|
||||
```text
|
||||
https://yourURL.com/application/o/bills/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
Keep local login enabled until you have tested authentik login with an admin-group user.
|
||||
|
||||
## Data, Imports, Exports, And Backups
|
||||
|
||||
BillTracker stores data in SQLite. By default the database is `db/bills.db`; set `DB_PATH` for a different location. In Docker, the image sets `DB_PATH=/data/db/bills.db` and `BACKUP_PATH=/data/backups`.
|
||||
|
||||
User data is scoped to the signed-in user. User exports include bills, categories, payments, monthly bill state, notes, and export metadata. They do not include password hashes, sessions, admin settings, SMTP credentials, backup files, server paths, or other users' data.
|
||||
|
||||
Data tools:
|
||||
|
||||
- XLSX spreadsheet import with preview before apply
|
||||
- user SQLite import from BillTracker user exports
|
||||
- user SQLite export
|
||||
- Excel workbook export
|
||||
- import history
|
||||
- admin full database backup, import, download, restore, delete, scheduled backups, and retention
|
||||
|
||||
Backups and exports contain sensitive financial data. The code writes SQLite backup files with restrictive file permissions, but backup/export encryption is not implemented. Protect downloaded files and backup storage yourself.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Auth is required for user data routes.
|
||||
- Admin routes require an admin session.
|
||||
- User-owned bill, category, payment, import, and export routes derive ownership from the authenticated session.
|
||||
- Local login, password change, import, export, admin actions, and OIDC routes have per-IP in-memory rate limits.
|
||||
- CORS is disabled unless `CORS_ORIGIN` is set.
|
||||
- Baseline security headers are sent; HSTS is sent only when `HTTPS=true`.
|
||||
- Session cookies are `httpOnly`, `sameSite=strict`, and marked secure when `COOKIE_SECURE=true`, `HTTPS=true`, or the request appears to be HTTPS.
|
||||
- OIDC validation is handled through `openid-client` using discovered provider metadata and JWKS.
|
||||
- Protect database files, backups, and exports as sensitive financial records.
|
||||
|
||||
## Reverse Proxy And HTTPS
|
||||
|
||||
Run BillTracker behind HTTPS for normal use. If TLS terminates at a reverse proxy, forward `X-Forwarded-Proto: https` so secure-cookie detection can work. You can also set `HTTPS=true` or `COOKIE_SECURE=true`.
|
||||
|
||||
Set `CORS_ORIGIN` only when the frontend and backend are served from different origins. For the normal same-origin deployment, leave it unset.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
client/ React app, pages, layout, UI components
|
||||
db/ SQLite connection, schema, startup migrations
|
||||
middleware/ auth checks, rate limits, security headers
|
||||
routes/ Express API routes
|
||||
services/ auth, OIDC, backups, imports, cleanup, status, notifications
|
||||
workers/ daily background tasks
|
||||
setup/ first-run admin setup
|
||||
scripts/ migrations and smoke/import tests
|
||||
public/ legacy static assets
|
||||
img/ app/runtime images and source screenshots
|
||||
docs/images/ README images
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
For a direct Node install:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Restart your process manager after building. The app initializes the SQLite schema and runs additive migrations on startup; the Docker entrypoint also runs `scripts/migrate-db.js` before starting unless `RUN_DB_MIGRATIONS=false`.
|
||||
|
||||
For Docker, pull/rebuild the image, recreate the container, and keep the `/data` volume mounted.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Admin backups and user exports are not encrypted by the app.
|
||||
- OIDC single logout is not implemented.
|
||||
- Content-Security-Policy is intentionally deferred.
|
||||
- Rate limiting is in-memory, so counters reset on restart and are not shared across multiple app instances.
|
||||
- authentik live login must be tested in your deployment with your authentik provider.
|
||||
- The XLSX parser dependency has known upstream security advisories; the import route is authenticated, file-size limited, and parses cells as data.
|
||||
|
||||
## License
|
||||
|
||||
License: Not specified.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
|
|
@ -6,10 +6,13 @@ import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
|||
import LoginPage from '@/pages/LoginPage';
|
||||
import AdminPage from '@/pages/AdminPage';
|
||||
import TrackerPage from '@/pages/TrackerPage';
|
||||
import CalendarPage from '@/pages/CalendarPage';
|
||||
import SummaryPage from '@/pages/SummaryPage';
|
||||
import BillsPage from '@/pages/BillsPage';
|
||||
import CategoriesPage from '@/pages/CategoriesPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import StatusPage from '@/pages/StatusPage';
|
||||
import AnalyticsPage from '@/pages/AnalyticsPage';
|
||||
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
||||
import DataPage from '@/pages/DataPage';
|
||||
import ProfilePage from '@/pages/ProfilePage';
|
||||
|
|
@ -35,8 +38,10 @@ function RequireAuth({ children, role }) {
|
|||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin');
|
||||
|
||||
// Role mismatch
|
||||
if (role && user.role !== role) {
|
||||
if (!roleAllowed) {
|
||||
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;
|
||||
}
|
||||
|
||||
|
|
@ -71,8 +76,11 @@ export default function App() {
|
|||
}
|
||||
>
|
||||
<Route index element={<TrackerPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="summary" element={<SummaryPage />} />
|
||||
<Route path="bills" element={<BillsPage />} />
|
||||
<Route path="categories" element={<CategoriesPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="data" element={<DataPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
|
|
|
|||
|
|
@ -108,6 +108,13 @@ export const api = {
|
|||
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`),
|
||||
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
||||
|
||||
// Calendar
|
||||
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
||||
|
||||
// Summary
|
||||
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
|
||||
saveSummaryIncome: (data) => put('/summary/income', data),
|
||||
|
||||
// Bills
|
||||
bills: () => get('/bills'),
|
||||
allBills: () => get('/bills?inactive=true'),
|
||||
|
|
@ -141,6 +148,16 @@ export const api = {
|
|||
settings: () => get('/settings'),
|
||||
saveSettings: (data) => put('/settings', data),
|
||||
|
||||
// Analytics
|
||||
analyticsSummary: (params = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
|
||||
});
|
||||
const query = qs.toString();
|
||||
return get(`/analytics/summary${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
// Status
|
||||
status: () => get('/status'),
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
</DialogHeader>
|
||||
|
||||
<form id="bill-modal-form" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-4 py-2">
|
||||
<div className="grid gap-x-5 gap-y-4 py-2 sm:grid-cols-2">
|
||||
|
||||
{/* Name */}
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -10,138 +10,257 @@ function hasHistoricalVisibility(bill) {
|
|||
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
|
||||
}
|
||||
|
||||
function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) {
|
||||
const hasHistory = hasHistoricalVisibility(bill);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
|
||||
onClick={() => onEdit?.(bill.id)}
|
||||
title={`Edit ${bill.name}`}
|
||||
>
|
||||
{bill.name}
|
||||
</button>
|
||||
{hasHistory && (
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
||||
title="Historical visibility configured"
|
||||
aria-label="Historical visibility configured"
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
bill.active
|
||||
? 'bg-emerald-500/15 text-emerald-500'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{bill.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{!!bill.autopay_enabled && (
|
||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500">AP</span>
|
||||
)}
|
||||
{!!bill.has_2fa && (
|
||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-400">2FA</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||
${Number(bill.expected_amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
||||
<p className="mt-0.5 text-sm text-foreground">Day {bill.due_day}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
||||
<p className="mt-0.5 truncate text-sm text-foreground">{bill.category_name || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Cycle</p>
|
||||
<p className="mt-0.5 text-sm capitalize text-foreground">{bill.billing_cycle || 'monthly'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 px-2.5 text-xs',
|
||||
bill.active
|
||||
? 'text-muted-foreground hover:text-destructive'
|
||||
: 'text-emerald-500 hover:text-emerald-400',
|
||||
)}
|
||||
onClick={() => onToggle?.(bill)}
|
||||
>
|
||||
{bill.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
{!bill.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
|
||||
onClick={() => onHistory?.(bill)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDelete?.(bill)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accepts row action handlers from BillsPage
|
||||
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
|
||||
return (
|
||||
<Table>
|
||||
|
||||
<TableHeader className="bg-muted border-b border-border/70">
|
||||
<TableRow className="hover:bg-transparent border-0">
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
|
||||
<TableHead className="px-6 py-3 w-72" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<>
|
||||
<div className="grid gap-3 p-3 lg:hidden">
|
||||
{bills.map((bill) => (
|
||||
<TableRow
|
||||
<MobileBillRow
|
||||
key={bill.id}
|
||||
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
|
||||
{/* Bill name */}
|
||||
<TableCell className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-sm font-medium leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
|
||||
onClick={() => onEdit?.(bill.id)}
|
||||
title={`Edit ${bill.name}`}
|
||||
>
|
||||
{bill.name}
|
||||
</button>
|
||||
{hasHistoricalVisibility(bill) && (
|
||||
<span
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
||||
title="Historical visibility configured"
|
||||
aria-label="Historical visibility configured"
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Category */}
|
||||
<TableCell className="px-6 py-4">
|
||||
{bill.category_name ? (
|
||||
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Due day */}
|
||||
<TableCell className="px-6 py-4 w-24">
|
||||
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Expected amount */}
|
||||
<TableCell className="px-6 py-4 w-28 text-right">
|
||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
${Number(bill.expected_amount).toFixed(2)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Billing cycle — field is billing_cycle, not cycle */}
|
||||
<TableCell className="px-6 py-4 w-28">
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{bill.billing_cycle || 'monthly'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Flags */}
|
||||
<TableCell className="px-6 py-4 w-24">
|
||||
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!!bill.autopay_enabled && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
|
||||
)}
|
||||
{!!bill.has_2fa && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions — visible on row hover */}
|
||||
<TableCell className="px-6 py-4 w-72 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-2.5 text-xs',
|
||||
bill.active
|
||||
? 'text-muted-foreground hover:text-destructive'
|
||||
: 'text-emerald-500 hover:text-emerald-400',
|
||||
)}
|
||||
onClick={() => onToggle?.(bill)}
|
||||
>
|
||||
{bill.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
{!bill.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
|
||||
onClick={() => onHistory?.(bill)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDelete?.(bill)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
</TableRow>
|
||||
bill={bill}
|
||||
onEdit={onEdit}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onHistory={onHistory}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</div>
|
||||
|
||||
</Table>
|
||||
<div className="hidden lg:block">
|
||||
<Table className="min-w-[900px]">
|
||||
|
||||
<TableHeader className="bg-muted border-b border-border/70">
|
||||
<TableRow className="hover:bg-transparent border-0">
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
|
||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
|
||||
<TableHead className="px-6 py-3 w-72" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{bills.map((bill) => (
|
||||
<TableRow
|
||||
key={bill.id}
|
||||
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
|
||||
{/* Bill name */}
|
||||
<TableCell className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-sm font-medium leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
|
||||
onClick={() => onEdit?.(bill.id)}
|
||||
title={`Edit ${bill.name}`}
|
||||
>
|
||||
{bill.name}
|
||||
</button>
|
||||
{hasHistoricalVisibility(bill) && (
|
||||
<span
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
||||
title="Historical visibility configured"
|
||||
aria-label="Historical visibility configured"
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Category */}
|
||||
<TableCell className="px-6 py-4">
|
||||
{bill.category_name ? (
|
||||
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Due day */}
|
||||
<TableCell className="px-6 py-4 w-24">
|
||||
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Expected amount */}
|
||||
<TableCell className="px-6 py-4 w-28 text-right">
|
||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
${Number(bill.expected_amount).toFixed(2)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Billing cycle — field is billing_cycle, not cycle */}
|
||||
<TableCell className="px-6 py-4 w-28">
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{bill.billing_cycle || 'monthly'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Flags */}
|
||||
<TableCell className="px-6 py-4 w-24">
|
||||
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!!bill.autopay_enabled && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
|
||||
)}
|
||||
{!!bill.has_2fa && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions — visible on row hover */}
|
||||
<TableCell className="px-6 py-4 w-72 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-2.5 text-xs',
|
||||
bill.active
|
||||
? 'text-muted-foreground hover:text-destructive'
|
||||
: 'text-emerald-500 hover:text-emerald-400',
|
||||
)}
|
||||
onClick={() => onToggle?.(bill)}
|
||||
>
|
||||
{bill.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
{!bill.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
|
||||
onClick={() => onHistory?.(bill)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDelete?.(bill)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Settings, ShieldCheck, Tag, User, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -16,12 +16,14 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { APP_VERSION } from '@/lib/version';
|
||||
|
||||
const userNavItems = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
{ to: '/status', icon: Activity, label: 'Status' },
|
||||
];
|
||||
|
|
@ -32,21 +34,21 @@ const adminNavItems = [
|
|||
|
||||
function BrandBlock({ adminMode = false }) {
|
||||
return (
|
||||
<NavLink to={adminMode ? '/admin' : '/'} className="flex items-center gap-3 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-primary text-primary-foreground font-bold text-sm shadow-sm shadow-primary/25">
|
||||
$
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground">BillTracker</span>
|
||||
{adminMode && (
|
||||
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums">v{APP_VERSION}</span>
|
||||
</div>
|
||||
<NavLink
|
||||
to={adminMode ? '/admin' : '/'}
|
||||
aria-label="BillTracker"
|
||||
className="flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<img
|
||||
src="/img/logo.png"
|
||||
alt="BillTracker"
|
||||
className="h-16 w-auto max-w-[9rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
|
||||
/>
|
||||
{adminMode && (
|
||||
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
|
@ -98,14 +100,16 @@ function UserMenu({ adminMode = false }) {
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
|
||||
{!adminMode && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
{user?.role === 'admin' && !adminMode && (
|
||||
<DropdownMenuItem onSelect={() => navigate('/admin')}>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Admin
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onSelect={handleLogout}>
|
||||
|
|
@ -119,14 +123,17 @@ function UserMenu({ adminMode = false }) {
|
|||
|
||||
export default function Sidebar({ adminMode = false }) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const items = adminMode ? adminNavItems : userNavItems;
|
||||
const { user } = useAuth();
|
||||
const items = user?.role === 'admin'
|
||||
? [...userNavItems, ...adminNavItems]
|
||||
: userNavItems;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/85 shadow-sm shadow-foreground/5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
|
||||
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
|
||||
<BrandBlock adminMode={adminMode} />
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} />
|
||||
))}
|
||||
|
|
@ -139,7 +146,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="md:hidden rounded-full bg-card/90"
|
||||
className="lg:hidden rounded-full bg-card/90"
|
||||
aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
|
|
@ -150,7 +157,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 md:hidden">
|
||||
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden">
|
||||
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function AlertDialogContent({ className, ...props }) {
|
|||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -131,3 +131,84 @@
|
|||
@apply surface overflow-hidden shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
header,
|
||||
.analytics-screen-header,
|
||||
.analytics-controls,
|
||||
.analytics-actions,
|
||||
.summary-screen-header,
|
||||
.summary-controls,
|
||||
.summary-actions,
|
||||
.summary-edit-actions,
|
||||
.summary-income-form {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
main,
|
||||
main > div {
|
||||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.analytics-page,
|
||||
.summary-page {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
.analytics-report-meta,
|
||||
.analytics-print-footer,
|
||||
.summary-print-meta,
|
||||
.summary-print-footer {
|
||||
display: block !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.analytics-report-meta h1,
|
||||
.summary-print-meta h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.analytics-report-meta p,
|
||||
.analytics-print-footer,
|
||||
.summary-print-meta p,
|
||||
.summary-print-footer {
|
||||
color: #4b5563 !important;
|
||||
font-size: 12px;
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
|
||||
.analytics-range {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-page input {
|
||||
border: 0 !important;
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
color: #111827 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.analytics-chart-grid {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.analytics-chart,
|
||||
.summary-card,
|
||||
.summary-chart-card {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #d1d5db !important;
|
||||
box-shadow: none !important;
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
export const APP_VERSION = '0.16.2';
|
||||
export const APP_VERSION = '0.18.1';
|
||||
export const APP_NAME = 'BillTracker';
|
||||
|
||||
export const RELEASE_NOTES = {
|
||||
version: '0.16.2',
|
||||
date: '2026-05-03',
|
||||
version: '0.18.1',
|
||||
date: '2026-05-04',
|
||||
highlights: [
|
||||
{ icon: '🗄️', title: 'SQLite data import', desc: 'Preview and import user-owned SQLite exports created by this app.' },
|
||||
{ icon: '🧾', title: 'Import tools layout', desc: 'Spreadsheet and SQLite import tools now sit side by side in Profile.' },
|
||||
{ icon: '📦', title: 'Exports below imports', desc: 'User data export downloads now live below the import tools.' },
|
||||
{ icon: '🎨', title: 'Material Design theme', desc: 'Light mode defaults to the shadcn Material Design theme tokens.' },
|
||||
{ icon: '📅', title: 'Due day editing', desc: 'Bill due dates are edited as recurring day-of-month values.' },
|
||||
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
|
||||
{ icon: '🧭', title: 'Tablet-safe navigation', desc: 'The top navigation uses the compact menu on tablet sizes to avoid horizontal overflow.' },
|
||||
{ icon: '📊', title: 'Responsive analytics', desc: 'Analytics controls, charts, and the pay heatmap resize or scroll cleanly on smaller screens.' },
|
||||
{ icon: '🪟', title: 'Viewport-safe dialogs', desc: 'Dialogs and confirmations fit mobile screens and scroll internally when content is long.' },
|
||||
{ icon: '🖥️', title: 'Desktop preserved', desc: 'Existing desktop layouts remain on the same large-screen breakpoints.' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,14 +23,16 @@ import AppNavigation from '@/components/layout/Sidebar';
|
|||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
|
||||
|
||||
function SectionHeading({ children }) {
|
||||
return <h2 className="text-base font-semibold text-foreground">{children}</h2>;
|
||||
}
|
||||
|
||||
function FieldRow({ label, children }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[200px_1fr] items-center gap-4">
|
||||
<Label className="text-right text-muted-foreground">{label}</Label>
|
||||
<div className="grid gap-2 lg:grid-cols-[200px_1fr] lg:items-center lg:gap-4">
|
||||
<Label className="text-muted-foreground lg:text-right">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -716,6 +718,12 @@ function AuthMethodsCard() {
|
|||
{/* OIDC / authentik login toggle */}
|
||||
<FieldRow label="authentik / OIDC login">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={AUTHENTIK_ICON_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 shrink-0 object-contain"
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.oidc_login_enabled}
|
||||
onChange={v => set('oidc_login_enabled', v)}
|
||||
|
|
@ -730,7 +738,15 @@ function AuthMethodsCard() {
|
|||
</FieldRow>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<p className="text-sm font-medium text-muted-foreground pt-1">authentik / OIDC configuration</p>
|
||||
<div className="flex items-center gap-2 pt-1 text-sm font-medium text-muted-foreground">
|
||||
<img
|
||||
src={AUTHENTIK_ICON_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 shrink-0 object-contain"
|
||||
/>
|
||||
<span>authentik / OIDC configuration</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Provider name">
|
||||
<Input
|
||||
|
|
@ -741,20 +757,20 @@ function AuthMethodsCard() {
|
|||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Issuer URL">
|
||||
<FieldRow label="Issuer / discovery URL">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={form.oidc_issuer_url}
|
||||
onChange={e => set('oidc_issuer_url', e.target.value)}
|
||||
placeholder="https://auth.example.com/application/o/bill-tracker/"
|
||||
placeholder="https://yourURL.com/application/o/bills/.well-known/openid-configuration"
|
||||
className="max-w-xl h-8 text-sm"
|
||||
/>
|
||||
<p className={issuerEndpointWarning ? 'text-xs text-amber-500' : 'text-xs text-muted-foreground'}>
|
||||
Use the authentik provider issuer URL, not the authorize/token/userinfo endpoint.
|
||||
Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration.
|
||||
</p>
|
||||
{issuerEndpointWarning && (
|
||||
<p className="text-xs text-amber-500">
|
||||
This looks like an authorization endpoint. In authentik, copy the OpenID Configuration Issuer value.
|
||||
This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -920,6 +936,12 @@ function AuthMethodsCard() {
|
|||
disabled={!data?.oidc_login_enabled || !data?.oidc_configured}
|
||||
onClick={() => { window.location.href = '/api/auth/oidc/login?redirect_to=/admin'; }}
|
||||
>
|
||||
<img
|
||||
src={AUTHENTIK_ICON_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-4 w-4 shrink-0 object-contain"
|
||||
/>
|
||||
Test authentik Login
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
|
|
@ -1152,7 +1174,7 @@ function AddUserCard({ onCreated }) {
|
|||
<CardTitle>Add User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="flex items-end gap-3">
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label htmlFor="new-uname">Username</Label>
|
||||
<Input
|
||||
|
|
@ -1399,8 +1421,8 @@ function BackupManagementCard() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="min-w-[860px] w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,565 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const RANGE_OPTIONS = [6, 12, 24, 36];
|
||||
const MONTH_OPTIONS = [
|
||||
['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'],
|
||||
['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'],
|
||||
['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'],
|
||||
];
|
||||
const CHART_OPTIONS = [
|
||||
['monthlyTrend', 'Monthly trend'],
|
||||
['expectedActual', 'Expected vs actual'],
|
||||
['categorySpend', 'Category spend'],
|
||||
['heatmap', 'Pay heatmap'],
|
||||
];
|
||||
const PALETTE = ['#7c3aed', '#10b981', '#ec4899', '#3b82f6', '#f59e0b', '#14b8a6', '#ef4444', '#8b5cf6'];
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function fullMoney(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function formatRange(range) {
|
||||
if (!range?.start || !range?.end) return 'Selected range';
|
||||
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
function hasData(rows, keys) {
|
||||
return rows?.some(row => keys.some(key => Number(row[key]) > 0));
|
||||
}
|
||||
|
||||
function EmptyState({ label = 'No analytics data for this selection.' }) {
|
||||
return (
|
||||
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 px-4 text-center text-sm text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, subtitle, children, summary }) {
|
||||
return (
|
||||
<section className="analytics-chart surface-elevated p-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
{summary && <div className="shrink-0 text-right text-sm font-semibold tabular-nums">{summary}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SvgFrame({ children, height = 260 }) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
|
||||
<svg viewBox={`0 0 720 ${height}`} role="img" className="h-auto w-full">
|
||||
{children}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineChart({ rows, area = false }) {
|
||||
if (!hasData(rows, ['total'])) return <EmptyState />;
|
||||
|
||||
const width = 720;
|
||||
const height = 260;
|
||||
const pad = { left: 58, right: 24, top: 24, bottom: 46 };
|
||||
const chartW = width - pad.left - pad.right;
|
||||
const chartH = height - pad.top - pad.bottom;
|
||||
const max = Math.max(...rows.map(r => r.total), 1);
|
||||
const points = rows.map((row, index) => {
|
||||
const x = pad.left + (rows.length === 1 ? chartW / 2 : (index / (rows.length - 1)) * chartW);
|
||||
const y = pad.top + chartH - (row.total / max) * chartH;
|
||||
return { ...row, x, y };
|
||||
});
|
||||
const line = points.map(p => `${p.x},${p.y}`).join(' ');
|
||||
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
|
||||
|
||||
return (
|
||||
<SvgFrame height={height}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
|
||||
const y = pad.top + chartH - tick * chartH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.09" />
|
||||
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{area && <polygon points={areaPoints} fill="#7c3aed" opacity="0.16" />}
|
||||
<polyline points={line} fill="none" stroke="#7c3aed" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{points.map((point, index) => (
|
||||
<g key={point.month}>
|
||||
<circle cx={point.x} cy={point.y} r="4.5" fill="#7c3aed" />
|
||||
{(rows.length <= 12 || index % 3 === 0) && (
|
||||
<text x={point.x} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
|
||||
{point.label}
|
||||
</text>
|
||||
)}
|
||||
<title>{`${point.label}: ${fullMoney(point.total)}`}</title>
|
||||
</g>
|
||||
))}
|
||||
</SvgFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedBarChart({ rows }) {
|
||||
if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />;
|
||||
|
||||
const width = 720;
|
||||
const height = 280;
|
||||
const pad = { left: 58, right: 24, top: 24, bottom: 50 };
|
||||
const chartW = width - pad.left - pad.right;
|
||||
const chartH = height - pad.top - pad.bottom;
|
||||
const max = Math.max(...rows.flatMap(r => [r.expected, r.actual]), 1);
|
||||
const groupW = chartW / rows.length;
|
||||
const barW = Math.max(5, Math.min(17, groupW * 0.28));
|
||||
|
||||
return (
|
||||
<SvgFrame height={height}>
|
||||
{[0, 0.5, 1].map(tick => {
|
||||
const y = pad.top + chartH - tick * chartH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.09" />
|
||||
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{rows.map((row, index) => {
|
||||
const center = pad.left + index * groupW + groupW / 2;
|
||||
const expectedH = (row.expected / max) * chartH;
|
||||
const actualH = (row.actual / max) * chartH;
|
||||
return (
|
||||
<g key={row.month}>
|
||||
<rect x={center - barW - 1} y={pad.top + chartH - expectedH} width={barW} height={expectedH} rx="4" fill="#8b5cf6">
|
||||
<title>{`${row.label} expected: ${fullMoney(row.expected)}`}</title>
|
||||
</rect>
|
||||
<rect x={center + 1} y={pad.top + chartH - actualH} width={barW} height={actualH} rx="4" fill="#10b981">
|
||||
<title>{`${row.label} actual: ${fullMoney(row.actual)}`}</title>
|
||||
</rect>
|
||||
{(rows.length <= 12 || index % 3 === 0) && (
|
||||
<text x={center} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
|
||||
{row.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<g transform={`translate(${width - 190}, 18)`} fontSize="12" fill="currentColor">
|
||||
<rect width="10" height="10" rx="2" fill="#8b5cf6" /><text x="16" y="10">Expected</text>
|
||||
<rect x="92" width="10" height="10" rx="2" fill="#10b981" /><text x="108" y="10">Actual</text>
|
||||
</g>
|
||||
</SvgFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function DonutChart({ rows }) {
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
|
||||
if (!total) return <EmptyState />;
|
||||
|
||||
let cumulative = 0;
|
||||
const radius = 78;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
|
||||
<div className="flex justify-center">
|
||||
<svg viewBox="0 0 220 220" role="img" className="h-56 w-56">
|
||||
<circle cx="110" cy="110" r={radius} fill="none" stroke="currentColor" strokeWidth="30" opacity="0.08" />
|
||||
{rows.map((row, index) => {
|
||||
const value = Number(row.total || 0);
|
||||
const dash = (value / total) * circumference;
|
||||
const segment = (
|
||||
<circle
|
||||
key={row.category_name}
|
||||
cx="110"
|
||||
cy="110"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={PALETTE[index % PALETTE.length]}
|
||||
strokeWidth="30"
|
||||
strokeDasharray={`${dash} ${circumference - dash}`}
|
||||
strokeDashoffset={-cumulative}
|
||||
transform="rotate(-90 110 110)"
|
||||
>
|
||||
<title>{`${row.category_name}: ${fullMoney(value)}`}</title>
|
||||
</circle>
|
||||
);
|
||||
cumulative += dash;
|
||||
return segment;
|
||||
})}
|
||||
<text x="110" y="104" textAnchor="middle" fontSize="13" fill="currentColor" opacity="0.65">Total</text>
|
||||
<text x="110" y="126" textAnchor="middle" fontSize="22" fontWeight="700" fill="currentColor">{money(total)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, index) => (
|
||||
<div key={row.category_name} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: PALETTE[index % PALETTE.length] }} />
|
||||
<span className="truncate">{row.category_name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-medium tabular-nums">{fullMoney(row.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HEATMAP_CLASS = {
|
||||
paid: 'bg-emerald-500/85 border-emerald-400/40',
|
||||
skipped: 'bg-sky-500/70 border-sky-400/40',
|
||||
missed: 'bg-red-500/75 border-red-400/40',
|
||||
no_data: 'bg-muted border-border',
|
||||
};
|
||||
|
||||
function Heatmap({ heatmap }) {
|
||||
const rows = heatmap?.rows || [];
|
||||
const months = heatmap?.months || [];
|
||||
if (!rows.length || !months.length) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-lg border border-border/60">
|
||||
<div className="min-w-[760px]">
|
||||
<div
|
||||
className="grid border-b border-border/60 bg-muted/30 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
|
||||
>
|
||||
<div className="px-3 py-2">Bill</div>
|
||||
{months.map(month => <div key={month.key} className="px-1 py-2 text-center">{month.label}</div>)}
|
||||
</div>
|
||||
{rows.map(row => (
|
||||
<div
|
||||
key={row.bill_id}
|
||||
className="grid border-b border-border/40 last:border-b-0"
|
||||
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
|
||||
>
|
||||
<div className="min-w-0 px-3 py-2">
|
||||
<p className="truncate text-sm font-medium">{row.bill_name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p>
|
||||
</div>
|
||||
{months.map(month => {
|
||||
const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 };
|
||||
return (
|
||||
<div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2">
|
||||
<span
|
||||
className={cn('h-5 w-5 rounded border', HEATMAP_CLASS[cell.status] || HEATMAP_CLASS.no_data)}
|
||||
title={`${row.bill_name}, ${month.label}: ${cell.status.replace('_', ' ')}${cell.amount_paid ? ` (${fullMoney(cell.amount_paid)})` : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{[
|
||||
['paid', 'Paid'],
|
||||
['skipped', 'Skipped'],
|
||||
['missed', 'Missed'],
|
||||
['no_data', 'No data'],
|
||||
].map(([status, label]) => (
|
||||
<span key={status} className="inline-flex items-center gap-1.5">
|
||||
<span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<span className="block text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSelect({ value, onChange, children, className }) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={cn('h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50', className)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const initial = currentMonth();
|
||||
const [year, setYear] = useState(initial.year);
|
||||
const [month, setMonth] = useState(initial.month);
|
||||
const [months, setMonths] = useState(12);
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [billId, setBillId] = useState('');
|
||||
const [includeInactive, setIncludeInactive] = useState(false);
|
||||
const [includeSkipped, setIncludeSkipped] = useState(true);
|
||||
const [trendMode, setTrendMode] = useState('line');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [visible, setVisible] = useState({
|
||||
monthlyTrend: true,
|
||||
expectedActual: true,
|
||||
categorySpend: true,
|
||||
heatmap: true,
|
||||
});
|
||||
|
||||
const params = useMemo(() => ({
|
||||
year,
|
||||
month,
|
||||
months,
|
||||
category_id: categoryId,
|
||||
bill_id: billId,
|
||||
include_inactive: includeInactive,
|
||||
include_skipped: includeSkipped,
|
||||
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.analyticsSummary(params);
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load analytics.');
|
||||
toast.error(err.message || 'Failed to load analytics.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const reset = () => {
|
||||
const next = currentMonth();
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
setMonths(12);
|
||||
setCategoryId('');
|
||||
setBillId('');
|
||||
setIncludeInactive(false);
|
||||
setIncludeSkipped(true);
|
||||
setTrendMode('line');
|
||||
setVisible({ monthlyTrend: true, expectedActual: true, categorySpend: true, heatmap: true });
|
||||
};
|
||||
|
||||
const totalCategorySpend = data?.category_spend?.reduce((sum, row) => sum + Number(row.total || 0), 0) || 0;
|
||||
const activeCharts = CHART_OPTIONS.filter(([key]) => visible[key]).map(([, label]) => label).join(', ') || 'None';
|
||||
const filterSummary = [
|
||||
categoryId ? `Category: ${data?.categories?.find(c => String(c.id) === String(categoryId))?.name || categoryId}` : 'All categories',
|
||||
billId ? `Bill: ${data?.bills?.find(b => String(b.id) === String(billId))?.name || billId}` : 'All bills',
|
||||
includeInactive ? 'Includes inactive bills' : 'Active bills only',
|
||||
includeSkipped ? 'Shows skipped months' : 'Hides skipped months',
|
||||
].join(' | ');
|
||||
|
||||
return (
|
||||
<div className="analytics-page space-y-6">
|
||||
<div className="analytics-report-meta hidden">
|
||||
<h1>BillTracker Analytics</h1>
|
||||
<p>{formatRange(data?.range)}</p>
|
||||
<p>{filterSummary}</p>
|
||||
<p>Visible charts: {activeCharts}</p>
|
||||
<p>Generated {new Date(data?.generated_at || Date.now()).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="analytics-screen-header flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Spending trends, category breakdowns, and payment history.
|
||||
</p>
|
||||
</div>
|
||||
<div className="analytics-actions flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="flex-1 sm:flex-none">
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Print
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Print / Save PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="analytics-controls surface-elevated p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<Field label="Ending month">
|
||||
<ControlSelect value={String(month)} onChange={value => setMonth(Number(value))}>
|
||||
{MONTH_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Ending year">
|
||||
<input
|
||||
type="number"
|
||||
min="2000"
|
||||
max="2100"
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value))}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Range">
|
||||
<ControlSelect value={String(months)} onChange={value => setMonths(Number(value))}>
|
||||
{RANGE_OPTIONS.map(value => <option key={value} value={value}>{value} months</option>)}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
<ControlSelect value={categoryId} onChange={value => { setCategoryId(value); setBillId(''); }}>
|
||||
<option value="">All categories</option>
|
||||
{(data?.categories || []).map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Bill">
|
||||
<ControlSelect value={billId} onChange={setBillId}>
|
||||
<option value="">All bills</option>
|
||||
{(data?.bills || []).map(bill => (
|
||||
<option key={bill.id} value={bill.id}>{bill.name}{bill.active ? '' : ' (inactive)'}</option>
|
||||
))}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Trend style">
|
||||
<ControlSelect value={trendMode} onChange={setTrendMode}>
|
||||
<option value="line">Line</option>
|
||||
<option value="area">Area</option>
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={reset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{CHART_OPTIONS.map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible[key]}
|
||||
onChange={e => setVisible(prev => ({ ...prev, [key]: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeInactive}
|
||||
onChange={e => setIncludeInactive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
||||
/>
|
||||
Include inactive bills
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSkipped}
|
||||
onChange={e => setIncludeSkipped(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
||||
/>
|
||||
Show skipped months
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="analytics-range text-sm text-muted-foreground">
|
||||
{data ? (
|
||||
<>
|
||||
Reporting on <span className="font-medium text-foreground">{formatRange(data.range)}</span>.
|
||||
<span className="ml-2">{filterSummary}</span>
|
||||
</>
|
||||
) : 'Preparing analytics...'}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{[1, 2, 3, 4].map(item => <div key={item} className="h-80 animate-pulse rounded-2xl bg-muted/50" />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<div className="analytics-chart-grid grid gap-5 xl:grid-cols-2">
|
||||
{visible.monthlyTrend && (
|
||||
<ChartCard title="Monthly spending trend" subtitle="Actual payments grouped by paid month.">
|
||||
<LineChart rows={data.monthly_spending || []} area={trendMode === 'area'} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{visible.expectedActual && (
|
||||
<ChartCard title="Expected vs actual spend" subtitle="Expected uses monthly override amount when present, otherwise the bill estimate.">
|
||||
<GroupedBarChart rows={data.expected_vs_actual || []} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{visible.categorySpend && (
|
||||
<ChartCard title="Spending by category" subtitle="Payments grouped by bill category." summary={fullMoney(totalCategorySpend)}>
|
||||
<DonutChart rows={data.category_spend || []} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{visible.heatmap && (
|
||||
<div className="xl:col-span-2">
|
||||
<ChartCard title="Pay-on-time heatmap" subtitle="Bill status by month. Future/current unpaid months show as no data.">
|
||||
<Heatmap heatmap={data.heatmap} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
{!Object.values(visible).some(Boolean) && (
|
||||
<div className="xl:col-span-2">
|
||||
<EmptyState label="Select at least one chart to show analytics." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="analytics-print-footer hidden text-xs text-muted-foreground">
|
||||
Generated from BillTracker Analytics on {new Date(data?.generated_at || Date.now()).toLocaleString()}.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -418,7 +418,7 @@ export default function BillsPage() {
|
|||
<div className="space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
||||
Manage
|
||||
|
|
@ -443,7 +443,7 @@ export default function BillsPage() {
|
|||
|
||||
{/* ── Active Bills ── */}
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border">
|
||||
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Active Bills
|
||||
</span>
|
||||
|
|
@ -493,7 +493,7 @@ export default function BillsPage() {
|
|||
|
||||
{showInactive && (
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border">
|
||||
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Inactive Bills
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,455 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, CircleDollarSign, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
}
|
||||
|
||||
function shiftMonth(year, month, delta) {
|
||||
const next = new Date(year, month - 1 + delta, 1);
|
||||
return { year: next.getFullYear(), month: next.getMonth() + 1 };
|
||||
}
|
||||
|
||||
function displayStatus(status) {
|
||||
if (status === 'due_soon') return 'Due';
|
||||
if (status === 'late') return 'Late';
|
||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status === 'paid' || status === 'autodraft') return 'bg-emerald-500/15 text-emerald-500 border-emerald-500/25';
|
||||
if (status === 'skipped') return 'bg-muted text-muted-foreground border-border';
|
||||
if (status === 'late' || status === 'missed') return 'bg-destructive/15 text-destructive border-destructive/25';
|
||||
return 'bg-primary/10 text-primary border-primary/25';
|
||||
}
|
||||
|
||||
function LegendItem({ className, label }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryProgress({ summary }) {
|
||||
const percent = Number(summary?.paid_percent || 0);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDollarSign className="h-4 w-4 text-emerald-500" />
|
||||
<CardTitle className="text-base">Total Expenses Paid</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Monthly progress across active, unskipped bills.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-2xl font-semibold tracking-tight">
|
||||
{fmt(summary?.paid_total)}
|
||||
<span className="mx-2 text-sm font-normal text-muted-foreground">/</span>
|
||||
<span className="text-base text-muted-foreground">{fmt(summary?.expected_total)}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{fmt(summary?.remaining_total)} remaining</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-semibold">{percent}%</p>
|
||||
<p className="text-xs text-muted-foreground">paid</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{summary?.bill_count || 0} active bills</span>
|
||||
<span>{summary?.paid_count || 0} paid</span>
|
||||
{!!summary?.skipped_count && <span>{summary.skipped_count} skipped</span>}
|
||||
{!!summary?.missed_count && <span className="text-destructive">{summary.missed_count} late or missed</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DayIndicators({ day }) {
|
||||
const summary = day.status_summary;
|
||||
const hasPaid = summary.paid_count > 0;
|
||||
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
|
||||
const hasSkipped = summary.skipped_count > 0;
|
||||
const hasMissed = summary.missed_count > 0;
|
||||
const paymentOnly = day.payments.length > 0 && day.bills_due.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mt-auto flex flex-wrap gap-1">
|
||||
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title="Paid" />}
|
||||
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
|
||||
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
|
||||
{hasMissed && <span className="h-1.5 w-1.5 rounded-full bg-destructive" title="Missed or late" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarGrid({ data, selectedDate, onSelectDay }) {
|
||||
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
|
||||
const cells = [
|
||||
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
|
||||
...data.days.map(day => ({ type: 'day', key: day.date, day })),
|
||||
];
|
||||
const today = todayStr();
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="grid grid-cols-7 border-b border-border/70 bg-muted/30">
|
||||
{WEEKDAYS.map(day => (
|
||||
<div key={day} className="px-1 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map(cell => {
|
||||
if (cell.type === 'blank') {
|
||||
return <div key={cell.key} className="min-h-16 border-b border-r border-border/50 bg-muted/10 sm:min-h-24" />;
|
||||
}
|
||||
|
||||
const day = cell.day;
|
||||
const isToday = day.date === today;
|
||||
const isSelected = day.date === selectedDate;
|
||||
const summary = day.status_summary;
|
||||
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0;
|
||||
const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count;
|
||||
const hasMissed = summary.missed_count > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => onSelectDay(day)}
|
||||
className={cn(
|
||||
'flex min-h-16 flex-col border-b border-r border-border/50 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
|
||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
hasActivity && 'bg-primary/[0.03] hover:bg-accent/60',
|
||||
isPaidDay && 'bg-emerald-500/[0.07]',
|
||||
hasMissed && 'bg-destructive/[0.08]',
|
||||
isSelected && 'ring-2 ring-primary ring-inset',
|
||||
)}
|
||||
aria-label={`View ${fmtDate(day.date)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium sm:text-sm',
|
||||
isToday && 'border border-primary bg-primary/10 text-primary',
|
||||
)}>
|
||||
{day.day}
|
||||
</span>
|
||||
{summary.due_count > 0 && (
|
||||
<span className="rounded bg-background/75 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
{summary.due_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
|
||||
{day.bills_due.slice(0, 2).map(bill => (
|
||||
<p key={bill.bill_id} className="truncate text-[11px] text-muted-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
))}
|
||||
{day.bills_due.length > 2 && (
|
||||
<p className="text-[11px] text-muted-foreground">+{day.bills_due.length - 2} more</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DayIndicators day={day} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DayDetailDialog({ day, open, onOpenChange }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">{day ? fmtDate(day.date) : 'Day details'}</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">Bills due and payments recorded for this date.</p>
|
||||
</DialogHeader>
|
||||
|
||||
{day && (
|
||||
<div className="space-y-5">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
|
||||
{day.bills_due.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||||
No bills are due on this day.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{day.bills_due.map(bill => (
|
||||
<div key={bill.bill_id} className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{bill.name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{bill.category_name || 'Uncategorized'}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('shrink-0 capitalize', statusTone(bill.status))}>
|
||||
{displayStatus(bill.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<p>Expected</p>
|
||||
<p className="font-mono text-sm text-foreground">{fmt(bill.effective_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Paid</p>
|
||||
<p className="font-mono text-sm text-emerald-500">{fmt(bill.paid_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Due</p>
|
||||
<p className="font-mono text-sm text-foreground">{fmtDate(bill.due_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Payments</h3>
|
||||
{day.payments.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||||
No payments were recorded on this day.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{day.payments.map(payment => (
|
||||
<div key={payment.payment_id} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{payment.bill_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{payment.method || 'Payment'}</p>
|
||||
</div>
|
||||
<span className="font-mono text-sm text-emerald-500">{fmt(payment.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-4">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/">Open Tracker</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/bills">Manage Bills</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const initial = currentMonth();
|
||||
const [year, setYear] = useState(initial.year);
|
||||
const [month, setMonth] = useState(initial.month);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.calendar(year, month);
|
||||
setData(result);
|
||||
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Calendar data could not be loaded.');
|
||||
toast.error(err.message || 'Calendar data could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, month]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
|
||||
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
|
||||
|
||||
function navigate(delta) {
|
||||
const next = shiftMonth(year, month, delta);
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
setSelectedDay(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const next = currentMonth();
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
setSelectedDay(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
Monthly Calendar
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Calendar</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
View bills, payments, and monthly progress by date.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center rounded-full border border-border/70 bg-card/90 p-1 shadow-sm">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-40 px-3 text-center text-sm font-semibold">{monthLabel}</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={goToday}>Today</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full" onClick={load} aria-label="Refresh calendar">
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card/70 px-4 py-3">
|
||||
<LegendItem className="border-emerald-500 bg-emerald-500" label="Paid" />
|
||||
<LegendItem className="border-primary bg-primary" label="Due" />
|
||||
<LegendItem className="border-muted-foreground/50 bg-muted-foreground/50" label="Skipped" />
|
||||
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="h-5 w-5 rounded-full border border-primary bg-primary/10" />
|
||||
Today
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-[360px] items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Loading calendar...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load}>Try again</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<CalendarGrid
|
||||
data={data}
|
||||
selectedDate={selectedDay?.date}
|
||||
onSelectDay={day => {
|
||||
setSelectedDay(day);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
{!hasAnyBills && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<CalendarDays className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">No bills on this calendar yet.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Add a bill to start seeing due dates and payment progress.</p>
|
||||
</div>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/bills">Add bill</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SummaryProgress summary={data?.summary} />
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Selected Day</CardTitle>
|
||||
<CardDescription>Tap a date to inspect bills and payments.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedDay ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">{fmtDate(selectedDay.date)}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-lg bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">Due</p>
|
||||
<p className="font-mono font-semibold">{fmt(selectedDay.status_summary.total_due)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">Paid</p>
|
||||
<p className="font-mono font-semibold text-emerald-500">{fmt(selectedDay.status_summary.total_paid)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" size="sm" onClick={() => setDetailOpen(true)}>
|
||||
View day details
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No day selected.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DayDetailDialog
|
||||
day={selectedDay}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,238 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown, Plus, Pencil, Trash2, ReceiptText,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputDialog } from '@/components/ui/input-dialog';
|
||||
import {
|
||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
|
||||
// ─── CategoriesPage ───────────────────────────────────────────────────────────
|
||||
function plural(count, label) {
|
||||
return `${count} ${label}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function billPreview(names = []) {
|
||||
if (!names.length) return 'No bills in this category yet.';
|
||||
const visible = names.slice(0, 4).join(', ');
|
||||
const more = names.length > 4 ? `, +${names.length - 4} more` : '';
|
||||
return `${visible}${more}`;
|
||||
}
|
||||
|
||||
function Chip({ value, label, tone = 'muted', details }) {
|
||||
const toneClass = {
|
||||
active: 'border-primary/25 bg-primary/10 text-primary',
|
||||
muted: 'border-border bg-muted/55 text-muted-foreground',
|
||||
info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
title={details || label}
|
||||
aria-label={details || label}
|
||||
className={cn(
|
||||
'inline-flex h-6 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 leading-relaxed">
|
||||
<p>{label}</p>
|
||||
{details && details !== label && <p className="opacity-85">{details}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChips({ category }) {
|
||||
const names = billPreview(category.bill_names);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Chip
|
||||
value={category.active_bill_count || 0}
|
||||
label={plural(category.active_bill_count || 0, 'active bill')}
|
||||
details={names}
|
||||
tone="active"
|
||||
/>
|
||||
<Chip
|
||||
value={category.inactive_bill_count || 0}
|
||||
label={plural(category.inactive_bill_count || 0, 'inactive bill')}
|
||||
details={names}
|
||||
/>
|
||||
<Chip
|
||||
value={category.payment_count || 0}
|
||||
label={plural(category.payment_count || 0, 'payment')}
|
||||
details={names}
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipLegend() {
|
||||
const items = [
|
||||
['Active', 'active'],
|
||||
['Inactive', 'muted'],
|
||||
['Payments', 'info'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{items.map(([label, tone]) => (
|
||||
<span key={label} className="inline-flex items-center gap-1.5">
|
||||
<span className={cn(
|
||||
'h-2.5 w-2.5 rounded-full border',
|
||||
tone === 'active' && 'border-primary/30 bg-primary/45',
|
||||
tone === 'muted' && 'border-border bg-muted',
|
||||
tone === 'info' && 'border-sky-500/30 bg-sky-500/45',
|
||||
)} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ active }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
active
|
||||
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: 'border-border bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BillName({ bill }) {
|
||||
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span title={label} className="font-medium text-foreground underline-offset-4 hover:underline">
|
||||
{bill.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-72 leading-relaxed">
|
||||
<p>{label}</p>
|
||||
<p className="opacity-85">
|
||||
{plural(bill.payment_count || 0, 'payment')} / {fmt(bill.total_paid)} paid
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedBills({ category }) {
|
||||
const bills = category.bills || [];
|
||||
|
||||
if (!bills.length) {
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/70 bg-background/65 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>No bills in this category yet.</span>
|
||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||
<Link to="/bills">Open Bills</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
|
||||
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold">Bill</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">Status</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Expected</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Due</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Paid</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Payments</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{bills.map(bill => (
|
||||
<tr key={bill.id} className="hover:bg-muted/25">
|
||||
<td className="px-4 py-3"><BillName bill={bill} /></td>
|
||||
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
|
||||
<td className="px-4 py-3 text-right font-mono">{fmt(bill.expected_amount)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{bill.due_day}</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:hidden">
|
||||
{bills.map(bill => (
|
||||
<div key={bill.id} className="rounded-lg border border-border/60 bg-background/75 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm"><BillName bill={bill} /></p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
|
||||
</div>
|
||||
<StatusPill active={bill.active} />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Expected</p>
|
||||
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.expected_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Paid</p>
|
||||
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.total_paid)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Payments</p>
|
||||
<p className="mt-0.5 font-semibold tabular-nums">{bill.payment_count || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Last Paid</p>
|
||||
<p className="mt-0.5 font-semibold tabular-nums">{fmtDate(bill.last_paid_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
const addInputRef = useRef(null);
|
||||
|
||||
// Rename dialog state
|
||||
const [renameTarget, setRenameTarget] = useState(null); // { id, name }
|
||||
const [renameTarget, setRenameTarget] = useState(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState(null); // { id, name }
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
|
@ -42,7 +248,21 @@ export default function CategoriesPage() {
|
|||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// ── Add ──────────────────────────────────────────────────────────────────────
|
||||
function toggleCategory(id) {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function onRowKeyDown(event, id) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggleCategory(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(e) {
|
||||
e.preventDefault();
|
||||
|
|
@ -66,9 +286,8 @@ export default function CategoriesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Rename ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function openRename(cat) {
|
||||
function openRename(event, cat) {
|
||||
event.stopPropagation();
|
||||
setRenameTarget(cat);
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +305,8 @@ export default function CategoriesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function openDelete(cat) {
|
||||
function openDelete(event, cat) {
|
||||
event.stopPropagation();
|
||||
setDeleteTarget(cat);
|
||||
}
|
||||
|
||||
|
|
@ -97,129 +315,178 @@ export default function CategoriesPage() {
|
|||
try {
|
||||
await api.deleteCategory(deleteTarget.id);
|
||||
toast.success(`"${deleteTarget.name}" deleted`);
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(deleteTarget.id);
|
||||
return next;
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
toast.error(err.message || 'Could not delete category.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page header — floats on bg-background */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{categories.length} categories</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card layer — lifted above page background */}
|
||||
<div className="table-surface">
|
||||
|
||||
{/* Card header with inline add form */}
|
||||
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
||||
<form onSubmit={handleAdd} className="flex gap-2 flex-1 max-w-sm">
|
||||
<Input
|
||||
ref={addInputRef}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New category name…"
|
||||
disabled={adding}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="h-8" disabled={adding || !newName.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{adding ? 'Adding…' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Category list */}
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
||||
No categories yet. Add one above.
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{plural(categories.length, 'category')}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{plural(totalBills, 'bill')}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="group flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">{cat.name}</span>
|
||||
{cat.bill_count > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cat.bill_count} {cat.bill_count === 1 ? 'bill' : 'bills'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => openRename(cat)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => openDelete(cat)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ChipLegend />
|
||||
</div>
|
||||
|
||||
<div className="table-surface overflow-hidden">
|
||||
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
|
||||
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
|
||||
<Input
|
||||
ref={addInputRef}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New category name..."
|
||||
disabled={adding}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{adding ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
No categories yet. Add one above.
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{categories.map((cat) => {
|
||||
const isExpanded = expanded.has(cat.id);
|
||||
const preview = billPreview(cat.bill_names);
|
||||
return (
|
||||
<section key={cat.id} className="bg-card/35">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
title={preview}
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
||||
className={cn(
|
||||
'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
|
||||
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isExpanded && 'bg-muted/25',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-180 text-foreground',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate text-sm font-semibold tracking-tight text-foreground" title={preview}>
|
||||
{cat.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-72 leading-relaxed">
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
<p className="opacity-85">{preview}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<StatChips category={cat} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(event) => openRename(event, cat)}
|
||||
aria-label={`Rename ${cat.name}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(event) => openDelete(event, cat)}
|
||||
aria-label={`Delete ${cat.name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && <ExpandedBills category={cat} />}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<ReceiptText className="h-3.5 w-3.5" />
|
||||
<span>Category totals include active and inactive bills in your account only.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* /card */}
|
||||
</div>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<InputDialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
||||
title="Rename Category"
|
||||
label="Name"
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Category name"
|
||||
confirmLabel="Rename"
|
||||
loading={renaming}
|
||||
onConfirm={handleRename}
|
||||
/>
|
||||
<InputDialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
||||
title="Rename Category"
|
||||
label="Name"
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Category name"
|
||||
confirmLabel="Rename"
|
||||
loading={renaming}
|
||||
onConfirm={handleRename}
|
||||
/>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bills in this category will become uncategorized. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete Category'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
</div>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bills in this category will become uncategorized. No bills or payments will be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Category'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ import { useAuth } from '@/hooks/useAuth';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { APP_VERSION } from '@/lib/version';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
|
||||
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setUser, refresh } = useAuth();
|
||||
|
|
@ -120,19 +124,17 @@ export default function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4 sm:p-6">
|
||||
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
{/* Logo / Brand */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary text-primary-foreground
|
||||
flex items-center justify-center font-bold text-lg shadow-sm">
|
||||
$
|
||||
</div>
|
||||
<span className="text-xl font-semibold tracking-tight">
|
||||
BillTracker
|
||||
</span>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src="/img/logo.png"
|
||||
alt="BillTracker"
|
||||
className="h-auto w-[82%] max-w-[22rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
|
|
@ -152,6 +154,12 @@ export default function LoginPage() {
|
|||
className="w-full"
|
||||
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
||||
>
|
||||
<img
|
||||
src={AUTHENTIK_ICON_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-5 w-5 shrink-0 object-contain"
|
||||
/>
|
||||
Continue with {providerName}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -205,6 +213,17 @@ export default function LoginPage() {
|
|||
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<a
|
||||
href={BUILD_LINK_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
Build v{APP_VERSION}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ function SectionCard({ title, children }) {
|
|||
|
||||
function SettingRow({ label, description, children }) {
|
||||
return (
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 mr-8">
|
||||
<div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1 min-w-0 sm:mr-8">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export default function StatusPage() {
|
|||
<div>
|
||||
|
||||
{/* Page header — flat on background */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Edit3,
|
||||
Loader2,
|
||||
Minus,
|
||||
Printer,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
function selectedFromToday() {
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
}
|
||||
|
||||
function shiftMonth(year, month, delta) {
|
||||
const next = new Date(year, month - 1 + delta, 1);
|
||||
return { year: next.getFullYear(), month: next.getMonth() + 1 };
|
||||
}
|
||||
|
||||
function monthLabel(year, month) {
|
||||
return `${MONTHS[month - 1]} ${year}`;
|
||||
}
|
||||
|
||||
function moneyClass(value) {
|
||||
return value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive';
|
||||
}
|
||||
|
||||
function StatusMark({ expense }) {
|
||||
if (expense.is_skipped) {
|
||||
return (
|
||||
<span className="inline-flex min-w-16 items-center justify-center rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Skipped
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (expense.is_paid) {
|
||||
return (
|
||||
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Paid
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
Open
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryChart({ rows = [] }) {
|
||||
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
||||
const chartRows = rows.map((row, index) => ({
|
||||
...row,
|
||||
label: row.type === 'Savings'
|
||||
? Number(row.amount) >= 0 ? 'Savings' : 'Shortfall'
|
||||
: row.type,
|
||||
color: index === 0
|
||||
? 'hsl(var(--chart-1))'
|
||||
: index === 1
|
||||
? 'hsl(var(--chart-3))'
|
||||
: Number(row.amount) >= 0
|
||||
? 'hsl(var(--chart-2))'
|
||||
: 'hsl(var(--destructive))',
|
||||
width: Math.max(2, (Math.abs(Number(row.amount) || 0) / max) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{chartRows.map(row => (
|
||||
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
|
||||
<div className="text-sm font-medium text-foreground">{row.label}</div>
|
||||
<div className="h-7 rounded-full bg-muted/70 p-1">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width]"
|
||||
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
|
||||
title={`${row.label}: ${fmt(row.amount)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}>
|
||||
{fmt(row.amount)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseRow({ expense }) {
|
||||
return (
|
||||
<div className="grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[minmax(0,1fr)_7.5rem_5.5rem] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-foreground">{expense.name}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{expense.category_name && <span>{expense.category_name}</span>}
|
||||
<span>Due day {expense.due_day}</span>
|
||||
{expense.actual_amount !== null && <span>Monthly amount</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground sm:text-right">{fmt(expense.display_amount)}</div>
|
||||
<div className="sm:justify-self-end" aria-label={expense.is_paid ? 'Paid' : expense.is_skipped ? 'Skipped' : 'Open'}>
|
||||
<StatusMark expense={expense} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SummaryPage() {
|
||||
const [selected, setSelected] = useState(selectedFromToday);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [incomeLabel, setIncomeLabel] = useState('Salary');
|
||||
const [incomeAmount, setIncomeAmount] = useState('0');
|
||||
const [editingIncome, setEditingIncome] = useState(false);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.summary(selected.year, selected.month);
|
||||
setData(result);
|
||||
setIncomeLabel(result.income?.label || 'Salary');
|
||||
setIncomeAmount(String(result.income?.amount ?? 0));
|
||||
setEditingIncome(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Summary could not be loaded.');
|
||||
toast.error(err.message || 'Summary could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selected.month, selected.year]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
const summary = data?.summary || {};
|
||||
const expenses = data?.expenses || [];
|
||||
|
||||
const generatedLabel = useMemo(() => {
|
||||
if (!data?.generated_at) return '';
|
||||
return new Date(data.generated_at).toLocaleString();
|
||||
}, [data?.generated_at]);
|
||||
|
||||
async function saveIncome() {
|
||||
const amount = Number(incomeAmount);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
toast.error('Enter a valid income amount.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveSummaryIncome({
|
||||
year: selected.year,
|
||||
month: selected.month,
|
||||
label: incomeLabel.trim() || 'Salary',
|
||||
amount,
|
||||
});
|
||||
toast.success('Income saved.');
|
||||
await loadSummary();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Income could not be saved.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function moveMonth(delta) {
|
||||
setSelected(current => shiftMonth(current.year, current.month, delta));
|
||||
}
|
||||
|
||||
function resetToday() {
|
||||
setSelected(selectedFromToday());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="summary-page mx-auto max-w-3xl space-y-5">
|
||||
<div className="summary-print-meta hidden">
|
||||
<h1>BillTracker Summary</h1>
|
||||
<p>{monthLabel(selected.year, selected.month)}</p>
|
||||
{generatedLabel && <p>Generated {generatedLabel}</p>}
|
||||
</div>
|
||||
|
||||
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Plan income, expenses, and monthly result.</p>
|
||||
</div>
|
||||
<div className="summary-actions flex gap-2">
|
||||
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Today
|
||||
</Button>
|
||||
<Button onClick={() => window.print()} className="sm:w-auto">
|
||||
<Printer className="h-4 w-4" />
|
||||
Print / PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-controls mx-auto flex w-full max-w-md items-center justify-between gap-2 rounded-full border border-border/70 bg-card/95 p-1.5 shadow-sm">
|
||||
<Button variant="ghost" size="icon" onClick={() => moveMonth(-1)} aria-label="Previous month">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 px-2 text-center">
|
||||
<CalendarDays className="hidden h-4 w-4 text-muted-foreground sm:block" />
|
||||
<div className="truncate text-base font-semibold text-foreground">{monthLabel(selected.year, selected.month)}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => moveMonth(1)} aria-label="Next month">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-72 items-center justify-center p-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading summary...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||
<Button variant="outline" onClick={loadSummary}>Retry</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<Card className="summary-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl">Monthly Plan</CardTitle>
|
||||
<CardDescription>{monthLabel(data.year, data.month)}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Income</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="summary-edit-actions h-7 px-2"
|
||||
onClick={() => setEditingIncome(value => !value)}
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
{editingIncome ? 'Close' : 'Edit'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="summary-income-display flex items-center justify-between gap-4 rounded-2xl bg-muted/45 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div>
|
||||
{Number(summary.income_total || 0) === 0 && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
|
||||
</div>
|
||||
|
||||
{editingIncome && (
|
||||
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[minmax(0,1fr)_10rem_auto] md:items-end">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Label</span>
|
||||
<Input value={incomeLabel} onChange={event => setIncomeLabel(event.target.value)} placeholder="Salary" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Amount</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={incomeAmount}
|
||||
onChange={event => setIncomeAmount(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Expenses</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Skipped bills are shown but not counted.</p>
|
||||
</div>
|
||||
<div className="hidden text-xs font-semibold uppercase tracking-wide text-muted-foreground sm:block">
|
||||
Paid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expenses.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
No bills found for this month.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
|
||||
{expenses.map(expense => (
|
||||
<ExpenseRow key={expense.bill_id} expense={expense} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-border/60 bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">Fully Paid Expenses</div>
|
||||
<div className="text-base font-bold text-foreground">{summary.paid_expense_count || 0} / {summary.expense_count || 0}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
|
||||
<div className="text-base font-semibold text-foreground">{fmt(summary.expense_total)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border/60 pt-3">
|
||||
<div className="text-base font-semibold text-foreground">Result</div>
|
||||
<div className={cn('text-2xl font-bold', moneyClass(summary.result || 0))}>{fmt(summary.result)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button onClick={() => window.print()} className="summary-actions w-full">
|
||||
<Printer className="h-4 w-4" />
|
||||
Print / PDF
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="summary-chart-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl">Total amount per type</CardTitle>
|
||||
<CardDescription>
|
||||
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SummaryChart rows={data.chart || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="summary-print-footer hidden text-xs text-muted-foreground">
|
||||
Generated {generatedLabel || 'now'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -650,7 +650,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
|
|
@ -661,7 +661,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
{/* Monthly state editor (gear icon) — always available */}
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
|
|
@ -698,6 +698,186 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
);
|
||||
}
|
||||
|
||||
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||
const amountRef = useRef(null);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
|
||||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
|
||||
const isSkipped = !!row.is_skipped;
|
||||
const effectiveStatus = isSkipped
|
||||
? 'skipped'
|
||||
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
||||
? 'paid'
|
||||
: row.status;
|
||||
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
||||
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
|
||||
|
||||
async function handleQuickPay() {
|
||||
const val = parseFloat(amountRef.current?.value);
|
||||
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Marked as paid');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
|
||||
'space-y-3 transition-colors',
|
||||
isSkipped ? 'opacity-55' : rowBg,
|
||||
)}
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{row.autopay_enabled && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-sky-500"
|
||||
title="Autopay"
|
||||
>
|
||||
AP
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
className={cn(
|
||||
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
|
||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
title="Edit bill"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
</div>
|
||||
{row.monthly_notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||
{row.monthly_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
||||
<p className="mt-0.5 font-mono text-sm text-foreground">{fmtDate(row.due_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
||||
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
||||
<p className={cn('mt-0.5 font-mono text-sm', row.actual_amount != null ? 'text-amber-500' : 'text-foreground')}>
|
||||
{fmt(threshold)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
||||
<p className={cn('mt-0.5 font-mono text-sm', remaining > 0 ? 'text-foreground' : 'text-emerald-500')}>
|
||||
{fmt(remaining)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Paid </span>
|
||||
<span className="font-mono text-emerald-500">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Date </span>
|
||||
<span className="font-mono text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{!isPaid && !isSkipped && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
type="number" min="0" step="0.01"
|
||||
defaultValue={threshold}
|
||||
className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60"
|
||||
title="Payment amount"
|
||||
aria-label={`${row.name} payment amount`}
|
||||
/>
|
||||
<Button
|
||||
size="sm" variant="default"
|
||||
onClick={handleQuickPay}
|
||||
className="h-8 px-3 text-xs font-semibold"
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Payment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Month
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
||||
<NotesCell row={row} refresh={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMbs && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
open={showMbs}
|
||||
onOpenChange={setShowMbs}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bucket ─────────────────────────────────────────────────────────────────
|
||||
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
||||
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
||||
|
|
@ -746,35 +926,51 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
|
||||
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5" />
|
||||
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
|
||||
Notes
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r, i) => (
|
||||
<Row
|
||||
key={r.id}
|
||||
row={r}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
index={i}
|
||||
onEditBill={onEditBill}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="grid gap-3 p-3 lg:hidden">
|
||||
{rows.map((r, i) => (
|
||||
<MobileTrackerRow
|
||||
key={r.id}
|
||||
row={r}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
index={i}
|
||||
onEditBill={onEditBill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Table className="min-w-[1120px]">
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
|
||||
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5" />
|
||||
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
|
||||
Notes
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r, i) => (
|
||||
<Row
|
||||
key={r.id}
|
||||
row={r}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
index={i}
|
||||
onEditBill={onEditBill}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -835,7 +1031,7 @@ export default function TrackerPage() {
|
|||
<div className="space-y-5">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
||||
Monthly Overview
|
||||
|
|
@ -875,7 +1071,7 @@ export default function TrackerPage() {
|
|||
</div>
|
||||
|
||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||
<div className="flex gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||
<SummaryCard type="expected" value={summary.total_expected} />
|
||||
<SummaryCard type="paid" value={summary.total_paid} />
|
||||
<SummaryCard type="remaining" value={summary.remaining} />
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 143 KiB |
|
|
@ -4,7 +4,18 @@ const fs = require('fs');
|
|||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db');
|
||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||
const DEFAULT_CATEGORIES = ['Housing', 'Utilities', 'Subscriptions', 'Insurance', 'Loans', 'Other'];
|
||||
const DEFAULT_CATEGORIES = [
|
||||
'Housing',
|
||||
'Utilities',
|
||||
'Credit Cards',
|
||||
'Loans',
|
||||
'Insurance',
|
||||
'Subscriptions',
|
||||
'Phone & Internet',
|
||||
'Transportation',
|
||||
'Medical',
|
||||
'Other',
|
||||
];
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
|
|
@ -141,6 +152,22 @@ function runMigrations() {
|
|||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
|
||||
console.log('[migration] monthly_bill_state table ensured');
|
||||
|
||||
// -- monthly_income: per-user monthly income for Summary planning (v0.18.1)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS monthly_income (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||
label TEXT NOT NULL DEFAULT 'Salary',
|
||||
amount REAL NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, year, month)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
||||
|
||||
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||
|
|
@ -349,14 +376,8 @@ function seedDefaults() {
|
|||
insert.run(key, value);
|
||||
}
|
||||
|
||||
const insertCat = db.prepare(
|
||||
'INSERT INTO categories (name) VALUES (?)'
|
||||
);
|
||||
|
||||
for (const name of DEFAULT_CATEGORIES) {
|
||||
const existing = db.prepare('SELECT id FROM categories WHERE user_id IS NULL AND name = ? COLLATE NOCASE').get(name);
|
||||
if (!existing) insertCat.run(name);
|
||||
}
|
||||
// Category defaults are user-scoped. They are applied by
|
||||
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
|
||||
}
|
||||
|
||||
function ensureUserDefaultCategories(userId) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
bill-tracker:
|
||||
build: .
|
||||
image: bill-tracker:local
|
||||
image: dream.scheller.ltd/null/billtracker:latest
|
||||
container_name: bill-tracker
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_PATH: /data/db/bills.db
|
||||
BACKUP_PATH: /data/backups
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
volumes:
|
||||
- bill-data:/data # persistent DB + backups
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
bill-data:
|
||||
driver: local
|
||||
ports:
|
||||
- "3030:3000"
|
||||
|
||||
environment:
|
||||
INIT_ADMIN_USER: admin
|
||||
INIT_ADMIN_PASS: changeme123
|
||||
|
||||
volumes:
|
||||
- /portainer/hosting/bill-tracker/data:/data
|
||||
|
||||
restart: unless-stopped
|
||||
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="/img/logo.png">
|
||||
<title>Bill Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function requireAuth(req, res, next) {
|
|||
}
|
||||
|
||||
function requireUser(req, res, next) {
|
||||
if (req.user?.role !== 'user') {
|
||||
if (!['user', 'admin'].includes(req.user?.role)) {
|
||||
return res.status(403).json({ error: 'Access denied: user account required' });
|
||||
}
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.17",
|
||||
"version": "0.18.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bill-tracker",
|
||||
"version": "0.17",
|
||||
"version": "0.18.1",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18",
|
||||
"version": "0.18.1",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 143 KiB |
|
|
@ -3,172 +3,13 @@
|
|||
<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>
|
||||
<meta http-equiv="refresh" content="0; url=/login">
|
||||
<title>BillTracker — Sign In</title>
|
||||
<script>
|
||||
window.location.replace('/login');
|
||||
</script>
|
||||
</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>
|
||||
<p><a href="/login">Continue to BillTracker sign in</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : NaN;
|
||||
}
|
||||
|
||||
function monthKey(year, month) {
|
||||
return `${year}-${String(month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function monthLabel(year, month) {
|
||||
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function addMonths(year, month, delta) {
|
||||
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
|
||||
}
|
||||
|
||||
function monthEndDate(year, month) {
|
||||
const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildMonths(endYear, endMonth, count) {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = addMonths(endYear, endMonth, index - count + 1);
|
||||
return {
|
||||
...value,
|
||||
key: monthKey(value.year, value.month),
|
||||
label: monthLabel(value.year, value.month),
|
||||
start: `${monthKey(value.year, value.month)}-01`,
|
||||
end: monthEndDate(value.year, value.month),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateSummaryQuery(query) {
|
||||
const now = new Date();
|
||||
const year = parseInteger(query.year, now.getFullYear());
|
||||
const month = parseInteger(query.month, now.getMonth() + 1);
|
||||
const months = parseInteger(query.months, 12);
|
||||
const categoryId = parseInteger(query.category_id, null);
|
||||
const billId = parseInteger(query.bill_id, null);
|
||||
const includeInactive = query.include_inactive === 'true';
|
||||
const includeSkipped = query.include_skipped !== 'false';
|
||||
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
||||
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||
}
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
||||
return { error: 'month must be an integer between 1 and 12' };
|
||||
}
|
||||
if (!Number.isInteger(months) || months < 1 || months > 36) {
|
||||
return { error: 'months must be an integer between 1 and 36' };
|
||||
}
|
||||
if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
|
||||
return { error: 'category_id must be a positive integer' };
|
||||
}
|
||||
if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
|
||||
return { error: 'bill_id must be a positive integer' };
|
||||
}
|
||||
|
||||
return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
|
||||
}
|
||||
|
||||
function isMonthInPast(year, month) {
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const targetMonthStart = new Date(year, month - 1, 1);
|
||||
return targetMonthStart < currentMonthStart;
|
||||
}
|
||||
|
||||
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
|
||||
const clauses = ['b.user_id = ?'];
|
||||
const params = [userId];
|
||||
if (!includeInactive) clauses.push('b.active = 1');
|
||||
if (categoryId) {
|
||||
clauses.push('b.category_id = ?');
|
||||
params.push(categoryId);
|
||||
}
|
||||
if (billId) {
|
||||
clauses.push('b.id = ?');
|
||||
params.push(billId);
|
||||
}
|
||||
return { where: clauses.join(' AND '), params };
|
||||
}
|
||||
|
||||
router.get('/summary', (req, res) => {
|
||||
const parsed = validateSummaryQuery(req.query);
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
|
||||
const startDate = rangeMonths[0].start;
|
||||
const endDate = rangeMonths[rangeMonths.length - 1].end;
|
||||
const billWhere = buildBillWhere({ ...parsed, userId });
|
||||
|
||||
const categories = db.prepare(`
|
||||
SELECT id, name
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
ORDER BY name COLLATE NOCASE
|
||||
`).all(userId);
|
||||
|
||||
const bills = db.prepare(`
|
||||
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
|
||||
c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
|
||||
WHERE ${billWhere.where}
|
||||
ORDER BY b.name COLLATE NOCASE
|
||||
`).all(...billWhere.params);
|
||||
|
||||
if (!bills.length) {
|
||||
return res.json({
|
||||
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
|
||||
filters: {
|
||||
category_id: parsed.categoryId,
|
||||
bill_id: parsed.billId,
|
||||
include_inactive: parsed.includeInactive,
|
||||
include_skipped: parsed.includeSkipped,
|
||||
},
|
||||
categories,
|
||||
bills: [],
|
||||
monthly_spending: [],
|
||||
expected_vs_actual: [],
|
||||
category_spend: [],
|
||||
heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
|
||||
generated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const billIds = bills.map(b => b.id);
|
||||
const placeholders = billIds.map(() => '?').join(',');
|
||||
|
||||
const paymentRows = db.prepare(`
|
||||
SELECT p.bill_id,
|
||||
substr(p.paid_date, 1, 7) AS month_key,
|
||||
SUM(p.amount) AS total
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.bill_id IN (${placeholders})
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
|
||||
`).all(userId, ...billIds, startDate, endDate);
|
||||
|
||||
const stateRows = db.prepare(`
|
||||
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
|
||||
FROM monthly_bill_state m
|
||||
JOIN bills b ON b.id = m.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND m.bill_id IN (${placeholders})
|
||||
AND (m.year * 100 + m.month) BETWEEN ? AND ?
|
||||
`).all(
|
||||
userId,
|
||||
...billIds,
|
||||
rangeMonths[0].year * 100 + rangeMonths[0].month,
|
||||
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
|
||||
);
|
||||
|
||||
const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
|
||||
const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
|
||||
|
||||
const monthly_spending = rangeMonths.map(m => {
|
||||
const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
|
||||
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
|
||||
}).filter(row => row.total > 0);
|
||||
|
||||
const expected_vs_actual = rangeMonths.map(m => {
|
||||
let expected = 0;
|
||||
let actual = 0;
|
||||
let skipped_count = 0;
|
||||
for (const bill of bills) {
|
||||
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
|
||||
const skipped = !!state?.is_skipped;
|
||||
if (skipped) skipped_count += 1;
|
||||
if (!skipped || parsed.includeSkipped) {
|
||||
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
|
||||
}
|
||||
if (!skipped) {
|
||||
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
month: m.key,
|
||||
label: m.label,
|
||||
expected: Number(expected.toFixed(2)),
|
||||
actual: Number(actual.toFixed(2)),
|
||||
skipped_count,
|
||||
};
|
||||
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
|
||||
|
||||
const categoryMap = new Map();
|
||||
for (const bill of bills) {
|
||||
const categoryId = bill.category_id || null;
|
||||
const key = categoryId == null ? 'uncategorized' : String(categoryId);
|
||||
const existing = categoryMap.get(key) || {
|
||||
category_id: categoryId,
|
||||
category_name: bill.category_name || 'Uncategorized',
|
||||
total: 0,
|
||||
};
|
||||
for (const m of rangeMonths) {
|
||||
existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
|
||||
}
|
||||
categoryMap.set(key, existing);
|
||||
}
|
||||
const category_spend = Array.from(categoryMap.values())
|
||||
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
|
||||
.filter(row => row.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const heatmapRows = bills.map(bill => {
|
||||
const cells = rangeMonths.map(m => {
|
||||
const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
|
||||
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
|
||||
const skipped = !!state?.is_skipped;
|
||||
let status = 'no_data';
|
||||
if (skipped) status = 'skipped';
|
||||
else if (paid) status = 'paid';
|
||||
else if (isMonthInPast(m.year, m.month)) status = 'missed';
|
||||
return {
|
||||
month: m.key,
|
||||
label: m.label,
|
||||
status,
|
||||
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
bill_id: bill.id,
|
||||
bill_name: bill.name,
|
||||
category_name: bill.category_name || 'Uncategorized',
|
||||
active: !!bill.active,
|
||||
cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
|
||||
filters: {
|
||||
category_id: parsed.categoryId,
|
||||
bill_id: parsed.billId,
|
||||
include_inactive: parsed.includeInactive,
|
||||
include_skipped: parsed.includeSkipped,
|
||||
},
|
||||
categories,
|
||||
bills: bills.map(b => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
category_id: b.category_id,
|
||||
category_name: b.category_name || 'Uncategorized',
|
||||
active: !!b.active,
|
||||
})),
|
||||
monthly_spending,
|
||||
expected_vs_actual,
|
||||
category_spend,
|
||||
heatmap: {
|
||||
months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
|
||||
rows: heatmapRows,
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
||||
|
||||
function clampDay(year, month, day) {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
return Math.min(Math.max(parseInt(day || 1, 10), 1), daysInMonth);
|
||||
}
|
||||
|
||||
function toDateString(year, month, day) {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function emptyDay(year, month, day) {
|
||||
return {
|
||||
date: toDateString(year, month, day),
|
||||
day,
|
||||
bills_due: [],
|
||||
payments: [],
|
||||
status_summary: {
|
||||
due_count: 0,
|
||||
paid_count: 0,
|
||||
skipped_count: 0,
|
||||
missed_count: 0,
|
||||
total_due: 0,
|
||||
total_paid: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/calendar?year=2026&month=5
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const year = parseInt(req.query.year || now.getFullYear(), 10);
|
||||
const month = parseInt(req.query.month || now.getMonth() + 1, 10);
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' });
|
||||
}
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
|
||||
}
|
||||
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
|
||||
const dayByDate = new Map(days.map(day => [day.date, day]));
|
||||
|
||||
const bills = db.prepare(`
|
||||
SELECT b.*, c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON b.category_id = c.id
|
||||
WHERE b.active = 1 AND b.user_id = ?
|
||||
ORDER BY b.due_day ASC, b.name ASC
|
||||
`).all(req.user.id);
|
||||
|
||||
const paymentsByBillStmt = db.prepare(`
|
||||
SELECT *
|
||||
FROM payments
|
||||
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY paid_date DESC
|
||||
`);
|
||||
|
||||
const monthlyStateStmt = db.prepare(`
|
||||
SELECT actual_amount, notes, is_skipped
|
||||
FROM monthly_bill_state
|
||||
WHERE bill_id = ? AND year = ? AND month = ?
|
||||
`);
|
||||
|
||||
const payments = db.prepare(`
|
||||
SELECT
|
||||
p.id AS payment_id,
|
||||
p.bill_id,
|
||||
b.name AS bill_name,
|
||||
p.amount,
|
||||
p.paid_date,
|
||||
p.method,
|
||||
p.notes
|
||||
FROM payments p
|
||||
JOIN bills b ON p.bill_id = b.id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
ORDER BY p.paid_date ASC, b.name ASC
|
||||
`).all(req.user.id, start, end);
|
||||
|
||||
for (const payment of payments) {
|
||||
const day = dayByDate.get(payment.paid_date);
|
||||
if (day) {
|
||||
day.payments.push({
|
||||
payment_id: payment.payment_id,
|
||||
bill_id: payment.bill_id,
|
||||
bill_name: payment.bill_name,
|
||||
amount: payment.amount,
|
||||
paid_date: payment.paid_date,
|
||||
method: payment.method || null,
|
||||
notes: payment.notes || null,
|
||||
});
|
||||
day.status_summary.total_paid += payment.amount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const calendarBills = bills.map(bill => {
|
||||
const billPayments = paymentsByBillStmt.all(bill.id, start, end);
|
||||
const row = buildTrackerRow(bill, billPayments, year, month, today);
|
||||
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
|
||||
const actualAmount = monthlyState?.actual_amount ?? null;
|
||||
const isSkipped = !!monthlyState?.is_skipped;
|
||||
const effectiveAmount = actualAmount ?? row.expected_amount;
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;
|
||||
const isAutodraft = row.status === 'autodraft';
|
||||
const status = isSkipped
|
||||
? 'skipped'
|
||||
: isPaidByThreshold
|
||||
? 'paid'
|
||||
: row.status;
|
||||
const isPaid = status === 'paid' || isAutodraft;
|
||||
const dueDay = clampDay(year, month, bill.due_day);
|
||||
const dueDate = toDateString(year, month, dueDay);
|
||||
|
||||
return {
|
||||
bill_id: bill.id,
|
||||
name: bill.name,
|
||||
due_date: dueDate,
|
||||
due_day: dueDay,
|
||||
expected_amount: row.expected_amount,
|
||||
actual_amount: actualAmount,
|
||||
effective_amount: effectiveAmount,
|
||||
category_name: bill.category_name || null,
|
||||
is_paid: isPaid,
|
||||
is_skipped: isSkipped,
|
||||
paid_amount: row.total_paid || 0,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
for (const bill of calendarBills) {
|
||||
const day = dayByDate.get(bill.due_date);
|
||||
if (!day) continue;
|
||||
|
||||
day.bills_due.push(bill);
|
||||
day.status_summary.due_count += 1;
|
||||
if (bill.is_paid) day.status_summary.paid_count += 1;
|
||||
if (bill.is_skipped) day.status_summary.skipped_count += 1;
|
||||
if (!bill.is_paid && !bill.is_skipped && (bill.status === 'late' || bill.status === 'missed')) {
|
||||
day.status_summary.missed_count += 1;
|
||||
}
|
||||
if (!bill.is_skipped) day.status_summary.total_due += bill.effective_amount || 0;
|
||||
}
|
||||
|
||||
const activeBills = calendarBills.filter(bill => !bill.is_skipped);
|
||||
const expectedTotal = activeBills.reduce((sum, bill) => sum + (bill.effective_amount || 0), 0);
|
||||
const paidTotal = activeBills.reduce((sum, bill) => sum + (bill.paid_amount || 0), 0);
|
||||
const remainingTotal = Math.max(0, expectedTotal - paidTotal);
|
||||
const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0;
|
||||
|
||||
res.json({
|
||||
year,
|
||||
month,
|
||||
today,
|
||||
days,
|
||||
summary: {
|
||||
expected_total: expectedTotal,
|
||||
paid_total: paidTotal,
|
||||
remaining_total: remainingTotal,
|
||||
paid_percent: paidPercent,
|
||||
bill_count: activeBills.length,
|
||||
paid_count: activeBills.filter(bill => bill.is_paid).length,
|
||||
skipped_count: calendarBills.filter(bill => bill.is_skipped).length,
|
||||
missed_count: activeBills.filter(bill => bill.status === 'late' || bill.status === 'missed').length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -6,7 +6,60 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
|||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
ensureUserDefaultCategories(req.user.id);
|
||||
res.json(db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY name ASC').all(req.user.id));
|
||||
|
||||
const categories = db.prepare(`
|
||||
SELECT id, user_id, name, created_at, updated_at
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
`).all(req.user.id);
|
||||
|
||||
const billsByCategory = db.prepare(`
|
||||
SELECT
|
||||
b.id,
|
||||
b.category_id,
|
||||
b.name,
|
||||
b.active,
|
||||
b.expected_amount,
|
||||
b.due_day,
|
||||
COUNT(p.id) AS payment_count,
|
||||
COALESCE(SUM(p.amount), 0) AS total_paid,
|
||||
MAX(p.paid_date) AS last_paid_date
|
||||
FROM bills b
|
||||
LEFT JOIN payments p
|
||||
ON p.bill_id = b.id
|
||||
AND p.deleted_at IS NULL
|
||||
WHERE b.user_id = ?
|
||||
AND b.category_id = ?
|
||||
GROUP BY b.id
|
||||
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
|
||||
`);
|
||||
|
||||
const shaped = categories.map(category => {
|
||||
const bills = billsByCategory.all(req.user.id, category.id).map(bill => ({
|
||||
...bill,
|
||||
active: !!bill.active,
|
||||
payment_count: Number(bill.payment_count || 0),
|
||||
total_paid: Number(bill.total_paid || 0),
|
||||
last_paid_date: bill.last_paid_date || null,
|
||||
}));
|
||||
|
||||
const activeBillCount = bills.filter(bill => bill.active).length;
|
||||
const inactiveBillCount = bills.length - activeBillCount;
|
||||
const paymentCount = bills.reduce((sum, bill) => sum + bill.payment_count, 0);
|
||||
|
||||
return {
|
||||
...category,
|
||||
bill_count: activeBillCount,
|
||||
active_bill_count: activeBillCount,
|
||||
inactive_bill_count: inactiveBillCount,
|
||||
payment_count: paymentCount,
|
||||
bill_names: bills.map(bill => bill.name),
|
||||
bills,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(shaped);
|
||||
});
|
||||
|
||||
// POST /api/categories
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { getCycleRange } = require('../services/statusService');
|
||||
|
||||
const DEFAULT_INCOME_LABEL = 'Salary';
|
||||
|
||||
function parseYearMonth(source) {
|
||||
const now = new Date();
|
||||
const year = parseInt(source.year || now.getFullYear(), 10);
|
||||
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
||||
|
||||
if (Number.isNaN(year) || year < 2000 || year > 2100) {
|
||||
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||
}
|
||||
if (Number.isNaN(month) || month < 1 || month > 12) {
|
||||
return { error: 'month must be an integer between 1 and 12' };
|
||||
}
|
||||
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function getIncome(db, userId, year, month) {
|
||||
const row = db.prepare(`
|
||||
SELECT id, label, amount
|
||||
FROM monthly_income
|
||||
WHERE user_id = ? AND year = ? AND month = ?
|
||||
`).get(userId, year, month);
|
||||
|
||||
return {
|
||||
id: row?.id || null,
|
||||
label: row?.label || DEFAULT_INCOME_LABEL,
|
||||
amount: money(row?.amount),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(db, userId, year, month) {
|
||||
const income = getIncome(db, userId, year, month);
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
|
||||
const billRows = db.prepare(`
|
||||
SELECT
|
||||
b.id AS bill_id,
|
||||
b.name,
|
||||
b.expected_amount,
|
||||
b.due_day,
|
||||
c.name AS category_name,
|
||||
m.actual_amount,
|
||||
m.is_skipped
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
|
||||
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||
WHERE b.user_id = ? AND b.active = 1
|
||||
ORDER BY b.due_day ASC, b.name ASC
|
||||
`).all(year, month, userId);
|
||||
|
||||
const billIds = billRows.map(row => row.bill_id);
|
||||
const paymentMap = new Map();
|
||||
|
||||
if (billIds.length > 0) {
|
||||
const placeholders = billIds.map(() => '?').join(', ');
|
||||
const payments = db.prepare(`
|
||||
SELECT p.bill_id, COUNT(p.id) AS payment_count, SUM(p.amount) AS paid_amount
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.bill_id IN (${placeholders})
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY p.bill_id
|
||||
`).all(userId, ...billIds, start, end);
|
||||
|
||||
for (const row of payments) {
|
||||
paymentMap.set(row.bill_id, {
|
||||
payment_count: row.payment_count || 0,
|
||||
paid_amount: money(row.paid_amount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expenses = billRows.map(row => {
|
||||
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
|
||||
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
|
||||
const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount);
|
||||
const paidAmount = money(payment.paid_amount);
|
||||
|
||||
return {
|
||||
bill_id: row.bill_id,
|
||||
name: row.name,
|
||||
expected_amount: money(row.expected_amount),
|
||||
actual_amount: hasActual ? money(row.actual_amount) : null,
|
||||
display_amount: displayAmount,
|
||||
is_paid: payment.payment_count > 0,
|
||||
paid_amount: paidAmount,
|
||||
payment_count: payment.payment_count,
|
||||
is_skipped: !!row.is_skipped,
|
||||
due_day: row.due_day,
|
||||
category_name: row.category_name || null,
|
||||
};
|
||||
});
|
||||
|
||||
const countedExpenses = expenses.filter(expense => !expense.is_skipped);
|
||||
const incomeTotal = money(income.amount);
|
||||
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
|
||||
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
|
||||
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||
const result = incomeTotal - expenseTotal;
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
income,
|
||||
expenses,
|
||||
summary: {
|
||||
income_total: incomeTotal,
|
||||
expense_total: expenseTotal,
|
||||
paid_expense_count: paidExpenseCount,
|
||||
expense_count: countedExpenses.length,
|
||||
paid_total: paidTotal,
|
||||
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
|
||||
result,
|
||||
},
|
||||
chart: [
|
||||
{ type: 'Income', amount: incomeTotal },
|
||||
{ type: 'Expenses', amount: expenseTotal },
|
||||
{ type: 'Savings', amount: result },
|
||||
],
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const parsed = parseYearMonth(req.query);
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const db = getDb();
|
||||
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
|
||||
});
|
||||
|
||||
router.put('/income', (req, res) => {
|
||||
const parsed = parseYearMonth(req.body || {});
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const amount = Number(req.body?.amount);
|
||||
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
|
||||
return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' });
|
||||
}
|
||||
|
||||
const label = String(req.body?.label || DEFAULT_INCOME_LABEL).trim().slice(0, 80) || DEFAULT_INCOME_LABEL;
|
||||
const db = getDb();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO monthly_income (user_id, year, month, label, amount, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, year, month) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
amount = excluded.amount,
|
||||
updated_at = datetime('now')
|
||||
`).run(req.user.id, parsed.year, parsed.month, label, amount);
|
||||
|
||||
res.json({
|
||||
year: parsed.year,
|
||||
month: parsed.month,
|
||||
income: getIncome(db, req.user.id, parsed.year, parsed.month),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -46,6 +46,9 @@ app.use('/api/bills', requireAuth, requireUser, require('./routes/bills'
|
|||
app.use('/api/payments', requireAuth, requireUser, require('./routes/payments'));
|
||||
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
|
||||
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
||||
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
||||
app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
|
||||
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
||||
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
||||
app.use('/api/version', require('./routes/version')); // public
|
||||
|
|
@ -61,6 +64,7 @@ app.use('/api/import', requireAuth, requireUser, importLimiter, require('
|
|||
app.use('/legacy', express.static(path.join(__dirname, 'legacy')));
|
||||
|
||||
// ── Modern UI (Vite build) ────────────────────────────────────────────────────
|
||||
app.get('/login.html', (req, res) => res.redirect(302, '/login'));
|
||||
app.use(express.static(DIST));
|
||||
app.get('*', (req, res) => res.sendFile(path.join(DIST, 'index.html')));
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
publicDir: 'client/public',
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './client') },
|
||||
},
|
||||
|
|
|
|||