Compare commits

..
21 Commits
Author SHA1 Message Date
soconnorandClaude Opus 4.8 3f3b1362a9 Fix clock-in failing when no client is selected
clockIn and create used `input.clientId?.trim() ?? null`, but clients
always send `""` (not undefined) for no-client. `??` doesn't catch the
empty string, so `""` was inserted into time_entries.client_id,
violating the foreign key to clients.id. Use `|| null` to normalize a
blank string to null, matching updateRunning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:43:21 -04:00
soconnor 40624f6e6f Document recurring invoice scheduler setup 2026-07-02 00:49:48 -04:00
soconnorandClaude Sonnet 5 d589b94580 Make CRON_SECRET optional; fix docker env validation failure
CRON_SECRET was required (min 32 chars) in production by env.js, but
neither docker-compose.yml nor docker-compose.coolify.yml passed it
through. Since the release container isn't covered by
SKIP_ENV_VALIDATION (build-stage only), bun run start failed env
validation on every docker deploy unless CRON_SECRET was manually
configured — even though it only gates the optional recurring-invoice
cron endpoint, which already handles being unset gracefully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:43:20 -04:00
soconnor da219b287e Support mobile home and live activity data 2026-07-01 02:55:16 -04:00
soconnor 316edb4b3e Prefer mobile auth cookie for app requests 2026-06-29 22:01:41 -04:00
soconnor 8866335c50 Accept mobile Better Auth session token header 2026-06-29 18:38:35 -04:00
soconnor fb731885de Support chunked Better Auth session cookies 2026-06-29 17:57:38 -04:00
soconnor 1605322bc9 Improve expense dialog button layout 2026-06-29 15:30:19 -04:00
soconnor ef826dffa9 Harden auth and mobile session handling 2026-06-29 15:21:31 -04:00
soconnor d0c916d659 Polish expenses dashboard 2026-06-29 01:30:20 -04:00
soconnor a66d05f24f Remove default access and secret keys for S3 in Docker Compose files for security enhancement 2026-06-29 01:15:05 -04:00
soconnor 122a3c64f5 Refactor Coolify deployment from MinIO to Garage
- Updated README and documentation to reflect the transition from MinIO to Garage for S3-compatible storage.
- Removed the MinIO-specific docker-compose files and replaced them with Garage configurations.
- Adjusted environment variables and service dependencies in docker-compose files to accommodate Garage.
- Modified application code to log hints for Garage instead of MinIO.
- Ensured all references to MinIO in the codebase and documentation are replaced with Garage.
2026-06-29 00:59:27 -04:00
soconnorandCursor 31151e7f39 Sync time entries with invoice lines and harden auth for mobile.
Link clocked time to invoice items with bidirectional sync, add entry editing on web, broaden session cookie detection for Expo clients, and handle API rate limits without signing users out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 21:44:19 -04:00
soconnorandCursor c267b3e1c8 Improve expenses receipts UX and Coolify MinIO deployment.
Extract receipt UI components, add view/edit/create dialog modes with list receipt previews, add docker-compose.coolify.yml and clearer COOLIFY/S3 path-style guidance for Application + MinIO setups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 00:47:59 -04:00
soconnor 5e6e51337a enable react compiler 2026-06-27 00:05:42 -04:00
soconnor b9a9b813d2 add receipts support 2026-06-26 23:07:43 -04:00
soconnorandCursor 85df7c4627 Default signups off, improve Docker deploy, fix onboarding step UI.
Show a disabled-registration state on the register page when DISABLE_SIGNUPS is true (default), document docker-deploy.sh with git-SHA image tags, and align onboarding progress circles and labels on a shared grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 13:14:25 -04:00
soconnorandCursor 6b73c32c25 Add bulk invoice import with templates and refresh import UX
Move invoice import configuration into settings, redesign the import flow with shared components and sample templates, document the demo account in README, and polish upload and button styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 12:39:46 -04:00
soconnorandCursor 5978c8d903 Fix opengraph image prerender so production builds succeed.
Drop file fetches and embedded fonts that fail during static OG generation; use default ImageResponse typography instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:45:51 -04:00
soconnorandCursor 3fb61ff4dd Polish onboarding, invoices, and time clock while promoting the first registrant to admin.
Refresh onboarding wizard and shell, tighten invoice edit/detail flows, align timer widgets with the redesigned clock panel, and assign admin role on first signup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:40:25 -04:00
soconnorandCursor c53f2e6c4d Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.
Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:08:22 -04:00
169 changed files with 11584 additions and 5745 deletions
+42 -21
View File
@@ -4,14 +4,15 @@
# #
# Quick start (local dev): # Quick start (local dev):
# cp .env.example .env.local # cp .env.example .env.local
# docker compose -f docker-compose.dev.yml up -d # docker compose -f docker-compose.dev.yml up -d # Postgres + Garage
# bun run db:push # or: bun run db:migrate # bun run db:push # or: bun run db:migrate
# bun run dev # bun run dev
# Garage S3 API: http://localhost:3900
# #
# Quick start (Docker app + Postgres): # Quick start (Docker app + Postgres):
# cp .env.example .env # cp .env.example .env
# # edit AUTH_SECRET + public URLs below # # edit AUTH_SECRET + public URLs below
# docker compose up -d --build # ./scripts/docker-deploy.sh
# #
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Build-time vs runtime (Docker) # Build-time vs runtime (Docker)
@@ -31,7 +32,8 @@
# emails, and MCP links. In the browser, sign-in uses the current page origin # emails, and MCP links. In the browser, sign-in uses the current page origin
# automatically so dev works when Next picks another port (e.g. 3002). # automatically so dev works when Next picks another port (e.g. 3002).
# #
# Updating production: git pull && docker compose up -d --build # Updating production: git pull && ./scripts/docker-deploy.sh
# (or: docker compose up -d --build). Plain `docker compose up -d` does NOT rebuild.
# Migrations run on every app start (idempotent — only pending SQL is applied). # Migrations run on every app start (idempotent — only pending SQL is applied).
# ============================================================================= # =============================================================================
@@ -62,8 +64,9 @@ NODE_ENV=development
# Set true when connecting to local Postgres without SSL (default for compose). # Set true when connecting to local Postgres without SSL (default for compose).
DB_DISABLE_SSL=true DB_DISABLE_SSL=true
# Dev-only: host port for `docker compose -f docker-compose.dev.yml` Postgres. # Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
POSTGRES_PORT=5432 POSTGRES_PORT=5432
GARAGE_API_PORT=3900
# Optional: if Next dev picks another port, you do not need to change URLs for # Optional: if Next dev picks another port, you do not need to change URLs for
# sign-in — the auth client uses window.location.origin in the browser. # sign-in — the auth client uses window.location.origin in the browser.
@@ -75,6 +78,10 @@ POSTGRES_PORT=5432
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000). # Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
WEB_PORT=3000 WEB_PORT=3000
# App image tag for docker-compose.yml (optional). docker-deploy.sh sets
# beenvoice:<git-sha> automatically; default without it is beenvoice:local.
# BEENVOICE_IMAGE=beenvoice:local
# Postgres credentials for docker-compose.yml `db` service. # Postgres credentials for docker-compose.yml `db` service.
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost). # DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
POSTGRES_USER=postgres POSTGRES_USER=postgres
@@ -85,27 +92,13 @@ POSTGRES_DB=postgres
# White-label defaults (optional) # White-label defaults (optional)
# ============================================================================= # =============================================================================
# Baked in at Docker build. After first deploy, admins can override many of # Baked in at Docker build. After first deploy, admins can override many of
# these from Settings → Appearance in the dashboard. # Optional white-label defaults (build-time). Users choose light/dark in Settings.
NEXT_PUBLIC_BRAND_NAME=beenvoice NEXT_PUBLIC_BRAND_NAME=beenvoice
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
NEXT_PUBLIC_BRAND_ICON=$ NEXT_PUBLIC_BRAND_ICON=$
# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
# Font prefs: brand | frutiger | platform | inter | serif
NEXT_PUBLIC_DEFAULT_FONT=brand
NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
# Corner radius: none | sm | md | lg | xl
NEXT_PUBLIC_DEFAULT_RADIUS=xl
# Sidebar chrome: floating | docked
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
# ============================================================================= # =============================================================================
# Email — Resend (optional) # Email — Resend (optional)
# ============================================================================= # =============================================================================
@@ -126,12 +119,40 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# Access control (optional) # Access control (optional)
# ============================================================================= # =============================================================================
# Block new email/password registrations. Use literal true or false. # Block new email/password registrations (default: true / signups off).
# DISABLE_SIGNUPS=true # Set DISABLE_SIGNUPS=false to allow new email/password signups.
# DISABLE_SIGNUPS=false
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron). # Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
# CRON_SECRET= # CRON_SECRET=
# =============================================================================
# Receipt storage — S3-compatible (optional)
# =============================================================================
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
# .data/receipts/ (dev-friendly). Works with AWS S3, Garage, Cloudflare R2, etc.
#
# S3_ENDPOINT — who can reach Garage?
# • Host dev (bun dev + docker-compose.dev.yml Garage on the host): localhost:3900
# • App in Docker (docker-compose.yml): http://garage:3900 (Compose service name)
# • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900
# with Connect to Predefined Network on both resources. Never bare "garage".
# - NEVER use localhost in production — inside the app container that is the app, not Garage.
#
# Local dev with docker-compose.dev.yml Garage (host `bun dev`):
S3_ENDPOINT=http://localhost:3900
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=GK3515373e4c851ebaad366558
S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
S3_REGION=garage
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
#
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
# ============================================================================= # =============================================================================
# SSO — Authentik OIDC (optional) # SSO — Authentik OIDC (optional)
# ============================================================================= # =============================================================================
+1
View File
@@ -24,6 +24,7 @@ next-env.d.ts
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.data/
# debug # debug
npm-debug.log* npm-debug.log*
-2
View File
@@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG BETTER_AUTH_URL=http://localhost:3000 ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile: # Low-memory Docker build profile:
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
# - skip tsc inside `next build` (run `bun run check` in CI instead) # - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \ ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=1 \
NODE_ENV=production \ NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \ SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \ NEXT_TELEMETRY_DISABLED=1 \
+44 -10
View File
@@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432):
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
``` ```
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
Apply schema (pick one): Apply schema (pick one):
```bash ```bash
@@ -80,13 +82,20 @@ bun run db:push # fast iteration during development
# bun run db:migrate # same migrations the Docker image runs in production # bun run db:migrate # same migrations the Docker image runs in production
``` ```
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`:
- Email: `demo@example.com`
- Password: `demo123`
The account includes a sample business, clients, and invoices (draft, sent, and paid).
### 4. Run ### 4. Run
```bash ```bash
bun run dev bun run dev
``` ```
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, then sign in. Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, or sign in with the demo account above.
## Docker deployment (app + database) ## Docker deployment (app + database)
@@ -123,29 +132,50 @@ docker compose build --no-cache app
### 2. First start (or after code changes) ### 2. First start (or after code changes)
```bash ```bash
docker compose up -d --build ./scripts/docker-deploy.sh
# or: bun run docker:deploy
# or: docker compose up -d --build
``` ```
`--build` is important. A plain `docker compose up -d` reuses the existing image and **does not** pick up new code from `git pull`. `--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:<sha>`) so each deploy gets a distinct image.
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network. App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### Scheduled recurring invoices
The app container does not run a cron daemon. It starts the web server with
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
when something calls `POST /api/cron/generate-recurring` with
`Authorization: Bearer $CRON_SECRET`.
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
external scheduler to call
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
### 3. Updating an existing deploy ### 3. Updating an existing deploy
```bash ```bash
git pull git pull
docker compose up -d --build # rebuild image, restart app, run any new migrations ./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
# or: docker compose up -d --build
``` ```
| Command | New code? | Migrations run? | | Command | New code? | Migrations run? |
|---------|-----------|-----------------| |---------|-----------|-----------------|
| `git pull` only | No | No | | `git pull` only | No | No |
| `docker compose up -d` (no `--build`) | No — old image | Only if the app container restarts (same image) | | `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
| `docker compose up -d --build` | Yes | Yes — on app container start | | `./scripts/docker-deploy.sh` or `docker compose up -d --build` | Yes | Yes — on app container start |
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) | | `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
To verify migration files match the journal before deploy: `bun run db:verify-journal`. To verify migration files match the journal before deploy: `bun run db:verify-journal`.
### Coolify
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
### 4. Sign-ups ### 4. Sign-ups
Registration is **enabled** by default. To block new email/password accounts: Registration is **enabled** by default. To block new email/password accounts:
@@ -176,7 +206,9 @@ beenvoice-web/
├── src/lib/ # auth, PDF, email, branding helpers ├── src/lib/ # auth, PDF, email, branding helpers
├── drizzle/ # SQL migrations ├── drizzle/ # SQL migrations
├── Dockerfile # Production image (migrate + next start) ├── Dockerfile # Production image (migrate + next start)
├── docker-compose.yml # App + Postgres (deploy) ├── docker-compose.yml # App + Postgres + Garage (deploy)
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
├── docker-compose.dev.yml # Postgres only (local dev) ├── docker-compose.dev.yml # Postgres only (local dev)
└── docs/ # Architecture and UI guides └── docs/ # Architecture and UI guides
``` ```
@@ -204,12 +236,13 @@ bun run lint:fix
bun run format:write bun run format:write
bun run typecheck bun run typecheck
# Docker helpers (Postgres only — uses Colima on macOS) # Docker helpers
bun run docker:up # colima start + docker-compose.dev.yml up -d bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
bun run docker:down # stop dev Postgres + colima bun run docker:down # stop dev Postgres + colima
bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d
``` ```
Full-stack deploy uses `docker compose up` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`. Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
## API surface ## API surface
@@ -235,6 +268,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
| Doc | Contents | | Doc | Contents |
|-----|----------| |-----|----------|
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP | | [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
| [docs/README.md](./docs/README.md) | Index of UI and product guides | | [docs/README.md](./docs/README.md) | Index of UI and product guides |
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development | | [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
+75
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "beenvoice", "name": "beenvoice",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@better-auth/expo": "^1.6.19", "@better-auth/expo": "^1.6.19",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
@@ -102,6 +103,60 @@
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1075.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-node": "^3.972.58", "@aws-sdk/middleware-flexible-checksums": "^3.974.33", "@aws-sdk/middleware-sdk-s3": "^3.972.54", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.31", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-login": "^3.972.55", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.58", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-ini": "^3.972.56", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/token-providers": "3.1074.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.33", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.8", "tslib": "^2.6.2" } }, "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.23", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.35", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1074.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.31", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@@ -488,6 +543,24 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@smithy/core": ["@smithy/core@3.26.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA=="],
"@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -766,6 +839,8 @@
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+74
View File
@@ -0,0 +1,74 @@
# Garage-only stack for Coolify when beenvoice runs as a separate Application resource.
#
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml
#
# ── Pair with a beenvoice Application (pick ONE) ───────────────────────────────
#
# A) Public Garage URL (most reliable — no shared Docker network required)
# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below).
# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com).
# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab.
# 4. beenvoice Application → S3_ENDPOINT=<that URL> → redeploy beenvoice.
#
# B) Internal Docker DNS (same Coolify destination network)
# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy.
# 2. beenvoice Application → same destination → enable "Connect to Predefined Network".
# 3. beenvoice → S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
#
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage).
# See docs/COOLIFY.md.
services:
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+124
View File
@@ -0,0 +1,124 @@
# beenvoice on Coolify — single Docker Compose resource (recommended).
#
# Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml
#
# 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik).
# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example).
# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network.
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
#
# Migrating from Application + separate Postgres + Garage compose:
# - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here.
# - Or keep external Postgres and remove the `db` service + volume from this file.
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
image: ${BEENVOICE_IMAGE:-beenvoice:coolify}
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-garage}
expose:
- "3000"
depends_on:
db:
condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+53
View File
@@ -17,5 +17,58 @@ services:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped restart: unless-stopped
# S3-compatible receipt storage for host dev (`bun dev`). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_dev_garage_meta:/var/lib/garage/meta
- beenvoice_dev_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes: volumes:
beenvoice_dev_pg_data: beenvoice_dev_pg_data:
beenvoice_dev_garage_meta:
beenvoice_dev_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+75 -2
View File
@@ -1,3 +1,13 @@
# Production stack (app + Postgres + Garage). Local dev Postgres/Garage: docker-compose.dev.yml
#
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
# or this file. S3_ENDPOINT=http://garage:3900 works only inside a single stack.
# Application + separate Garage → docs/COOLIFY.md.
#
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
# the existing local image and will NOT include new code. Use:
# ./scripts/docker-deploy.sh
# docker compose up -d --build
services: services:
app: app:
build: build:
@@ -5,7 +15,9 @@ services:
args: args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
image: beenvoice:local # Fixed default tag (beenvoice:local) is reused until you --build. docker-deploy.sh
# sets BEENVOICE_IMAGE=beenvoice:<git-sha> so each deploy gets a fresh tag.
image: ${BEENVOICE_IMAGE:-beenvoice:local}
environment: environment:
NODE_ENV: production NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
@@ -18,16 +30,24 @@ services:
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false} DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-} AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
S3_REGION: ${S3_REGION:-garage}
ports: ports:
- "${WEB_PORT:-${PORT:-3000}}:3000" - "${WEB_PORT:-${PORT:-3000}}:3000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
db: db:
@@ -46,5 +66,58 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# S3-compatible receipt storage (~50100 MB RAM vs MinIO). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes: volumes:
beenvoice_pg_data: beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+2
View File
@@ -190,6 +190,8 @@ App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run s
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars. Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
**Deploy / update:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code. The deploy script tags images as `beenvoice:<git-sha>`.
## Scripts ## Scripts
```bash ```bash
+137
View File
@@ -0,0 +1,137 @@
# Coolify deployment — beenvoice + Garage
beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. [Garage](https://garagehq.deuxfleurs.fr/) is the default on self-hosted Coolify (~50100 MB RAM vs MinIO's ~500 MB+).
## Why `getaddrinfo ENOTFOUND garage` happens
Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://garage:3900` work? |
|-------|--------------------------------|
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed |
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage.
---
## Quick fix — keep beenvoice as Application + separate Garage compose
Use this if you are **not** migrating to a single Compose stack today.
### Path A — public Garage URL (recommended, works without shared Docker network)
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource.
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`).
4. On the **beenvoice Application** → Environment:
```env
S3_ENDPOINT=https://s3.yourdomain.com
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<same as GARAGE_DEFAULT_ACCESS_KEY / S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<same as GARAGE_DEFAULT_SECRET_KEY / S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
5. **Redeploy beenvoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for Garage behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets.
### Path B — internal Docker DNS (same destination, no public Garage domain)
Use when you want S3 API traffic to stay on the Docker network.
1. Put beenvoice Application and Garage Compose in the **same Coolify project** and **same destination** (server/network).
2. **Garage Compose resource****Advanced** → enable **Connect to Predefined Network****redeploy Garage**.
3. **beenvoice Application****Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy beenvoice**.
4. Find the Garage resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the Garage container).
5. Set on beenvoice Application:
```env
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
```
Example: resource UUID `k8w2o0g4s0g8``S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A).
6. Match credentials and bucket:
```env
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
---
## Recommended long-term — one Compose stack
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc.
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
6. Redeploy.
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
### Migrating from Application + external Postgres + Garage (or legacy MinIO)
| Current | Action |
|---------|--------|
| beenvoice Application | Remove after Compose stack is live |
| Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file |
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
---
## Compose file reference
| File | Purpose |
|------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
---
## Checklist (Application + separate Garage)
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900`
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900`
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
- [ ] Redeployed beenvoice after env or network changes
## Verify from the beenvoice container
```bash
# Shell into beenvoice app container on the Coolify server
docker exec -it <beenvoice-container> sh
# Path A — public URL (403/404 on root is fine — confirms DNS + TLS)
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
# Path B — internal host from S3_ENDPOINT
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900"
```
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
+1
View File
@@ -8,6 +8,7 @@
|----------|-------------| |----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract | | [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
| [../README.md](../README.md) | Install, scripts, deployment | | [../README.md](../README.md) | Install, scripts, deployment |
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
## UI & product guides ## UI & product guides
@@ -0,0 +1,39 @@
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle";
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp;
-- Users who already have a business are treated as onboarded
UPDATE "beenvoice_user" u
SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW())
WHERE u."onboardingCompletedAt" IS NULL
AND EXISTS (
SELECT 1
FROM "beenvoice_business" b
WHERE b."createdById" = u."id"
);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "beenvoice_audit_log" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"actorUserId" varchar(255) NOT NULL,
"action" varchar(100) NOT NULL,
"targetType" varchar(50) NOT NULL,
"targetId" varchar(255),
"metadata" jsonb,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_audit_log"
ADD CONSTRAINT "beenvoice_audit_log_actorUserId_beenvoice_user_id_fk"
FOREIGN KEY ("actorUserId") REFERENCES "public"."beenvoice_user"("id")
ON DELETE NO ACTION ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx" ON "beenvoice_audit_log" USING btree ("actorUserId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "beenvoice_audit_log" USING btree ("action");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx" ON "beenvoice_audit_log" USING btree ("createdAt");
@@ -0,0 +1,43 @@
CREATE INDEX IF NOT EXISTS "expense_business_id_idx" ON "beenvoice_expense" USING btree ("businessId");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_expense_receipt" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"expenseId" varchar(255) NOT NULL,
"storageKey" varchar(500) NOT NULL,
"originalFilename" varchar(255) NOT NULL,
"mimeType" varchar(100) NOT NULL,
"sizeBytes" integer NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_expense_receipt"
ADD CONSTRAINT "beenvoice_expense_receipt_expenseId_beenvoice_expense_id_fk"
FOREIGN KEY ("expenseId") REFERENCES "public"."beenvoice_expense"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "expense_receipt_expense_id_idx" ON "beenvoice_expense_receipt" USING btree ("expenseId");
--> statement-breakpoint
UPDATE "beenvoice_expense" e
SET "businessId" = i."businessId"
FROM "beenvoice_invoice" i
WHERE e."invoiceId" = i.id
AND e."businessId" IS NULL
AND i."businessId" IS NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_expense" e
SET "businessId" = sub.business_id
FROM (
SELECT
e2.id AS expense_id,
(
SELECT b2.id
FROM "beenvoice_business" b2
WHERE b2."createdById" = e2."createdById"
ORDER BY b2."isDefault" DESC, b2."createdAt" DESC
LIMIT 1
) AS business_id
FROM "beenvoice_expense" e2
WHERE e2."businessId" IS NULL
) sub
WHERE e.id = sub.expense_id
AND sub.business_id IS NOT NULL;
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
+42
View File
@@ -120,6 +120,48 @@
"when": 1781500000000, "when": 1781500000000,
"tag": "0016_fix_send_reminder_at_column", "tag": "0016_fix_send_reminder_at_column",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781600000000,
"tag": "0017_drop_theme_engine_columns",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781700000000,
"tag": "0018_user_onboarding",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1781800000000,
"tag": "0019_pdf_font_family",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1781900000000,
"tag": "0020_pdf_numeric_font_family",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1782000000000,
"tag": "0021_audit_log",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1782200000000,
"tag": "0023_invoice_item_time_entry",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle";
export default tseslint.config( export default tseslint.config(
{ {
ignores: [".next"], ignores: [".next", "scripts/**"],
}, },
...nextCoreWebVitals, ...nextCoreWebVitals,
{ {
+2
View File
@@ -15,6 +15,7 @@
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:deploy": "./scripts/docker-deploy.sh",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -26,6 +27,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@better-auth/expo": "^1.6.19", "@better-auth/expo": "^1.6.19",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
# Rebuilds the app image from the current working tree, then starts/restarts services
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}.
#
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
# changes from `git pull`. Always pass --build or use this script after pulling.
cd "$(dirname "$0")/.."
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if [[ -z "${BEENVOICE_IMAGE:-}" ]] && command -v git >/dev/null 2>&1; then
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
BEENVOICE_IMAGE="beenvoice:$(git rev-parse --short HEAD)"
export BEENVOICE_IMAGE
fi
fi
BEENVOICE_IMAGE="${BEENVOICE_IMAGE:-beenvoice:local}"
export BEENVOICE_IMAGE
echo "Deploying ${BEENVOICE_IMAGE} (docker compose up -d --build)..."
exec docker compose up -d --build "$@"
+22 -64
View File
@@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
import { Resend } from "resend"; import { sendPasswordResetForUser } from "~/lib/password-reset";
import { env } from "~/env"; import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import crypto from "crypto";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -15,22 +13,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Email is required" }, { status: 400 }); return NextResponse.json({ error: "Email is required" }, { status: 400 });
} }
// Validate email format const normalizedEmail = email.toLowerCase().trim();
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
windowMs: 60 * 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(normalizedEmail)) {
return NextResponse.json( return NextResponse.json(
{ error: "Invalid email format" }, { error: "Invalid email format" },
{ status: 400 }, { status: 400 },
); );
} }
// Check if user exists
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()), where: eq(users.email, normalizedEmail),
columns: { id: true },
}); });
// Always return success to prevent email enumeration attacks
// Don't reveal whether the user exists or not
if (!user) { if (!user) {
return NextResponse.json( return NextResponse.json(
{ {
@@ -42,60 +53,7 @@ export async function POST(request: NextRequest) {
); );
} }
// Generate reset token await sendPasswordResetForUser(user.id);
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Update user with reset token
await db
.update(users)
.set({
resetToken,
resetTokenExpiry,
})
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Send password reset email using Resend
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
await resend.emails.send({
from: "beenvoice <noreply@beenvoice.com>",
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
console.log(`Password reset email sent to: ${email}`);
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
// Continue execution - don't fail the request if email fails
// This prevents revealing whether an account exists based on email delivery
}
return NextResponse.json( return NextResponse.json(
{ {
+28 -1
View File
@@ -3,6 +3,9 @@ import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { resolveNewUserRole } from "~/lib/first-admin";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
@@ -69,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
windowMs: 60 * 60 * 1000,
max: 5,
});
if (rateLimit) return rateLimit;
if (env.DISABLE_SIGNUPS === true) { if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json( return NextResponse.json(
{ error: "New account registration is currently disabled" }, { error: "New account registration is currently disabled" },
@@ -104,13 +113,22 @@ export async function POST(request: NextRequest) {
const { firstName, lastName, email, password } = parsed.data; const { firstName, lastName, email, password } = parsed.data;
const normalizedEmail = email.toLowerCase(); const normalizedEmail = email.toLowerCase();
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:register-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, normalizedEmail), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "Registration failed. Please check the form or sign in." },
{ status: 400 }, { status: 400 },
); );
} }
@@ -118,12 +136,15 @@ export async function POST(request: NextRequest) {
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const role = await resolveNewUserRole(tx);
const [user] = await tx const [user] = await tx
.insert(users) .insert(users)
.values({ .values({
name: `${firstName} ${lastName}`, name: `${firstName} ${lastName}`,
email: normalizedEmail, email: normalizedEmail,
password: hashedPassword, password: hashedPassword,
role,
}) })
.returning({ id: users.id }); .returning({ id: users.id });
@@ -161,6 +182,12 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
console.error("Registration error:", error); console.error("Registration error:", error);
const databaseSetupError = getDatabaseSetupErrorMessage(error);
if (databaseSetupError) {
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
}
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 }, { status: 500 },
+23 -1
View File
@@ -1,11 +1,20 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { revokeUserSessions } from "~/lib/session-security";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
windowMs: 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const { token, password } = (await request.json()) as { const { token, password } = (await request.json()) as {
token: string; token: string;
password: string; password: string;
@@ -29,10 +38,21 @@ export async function POST(request: NextRequest) {
); );
} }
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired // Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and( where: and(
eq(users.resetToken, token), eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()), gt(users.resetTokenExpiry, new Date()),
), ),
}); });
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
} }
}); });
await revokeUserSessions(user.id);
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
+20 -1
View File
@@ -1,20 +1,39 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), {
windowMs: 60 * 1000,
max: 20,
});
if (ipRateLimit) return ipRateLimit;
const { token } = (await request.json()) as { token: string }; const { token } = (await request.json()) as { token: string };
if (!token || typeof token !== "string") { if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 }); return NextResponse.json({ error: "Token is required" }, { status: 400 });
} }
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:validate-reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired // Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and( where: and(
eq(users.resetToken, token), eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()), gt(users.resetTokenExpiry, new Date()),
), ),
}); });
+8 -1
View File
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization"); const authHeader = req.headers.get("authorization");
const secret = env.CRON_SECRET; const secret = env.CRON_SECRET;
if (secret && authHeader !== `Bearer ${secret}`) { if (!secret) {
return NextResponse.json(
{ error: "Cron secret is not configured" },
{ status: 500 },
);
}
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
+6
View File
@@ -42,6 +42,12 @@ export async function GET(
const pdfBlob = await generateInvoicePDFBlob(invoice, { const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined, pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor, pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText, pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
+2 -1
View File
@@ -3,6 +3,7 @@ import { z, type ZodType } from "zod";
import { createCaller } from "~/server/api/root"; import { createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc"; import { createTRPCContext } from "~/server/api/trpc";
import { getAppUrl } from "~/lib/app-url";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -856,7 +857,7 @@ const tools = {
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }), schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
handler: async (input, caller) => { handler: async (input, caller) => {
const result = await caller.invoices.generatePublicToken(input); const result = await caller.invoices.generatePublicToken(input);
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; const base = getAppUrl();
return { return {
...result, ...result,
webUrl: `${base}/i/${result.token}`, webUrl: `${base}/i/${result.token}`,
+39
View File
@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getOptionalServerSession } from "~/lib/auth-server";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { expenseReceipts } from "~/server/db/schema";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getOptionalServerSession(req.headers);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const receipt = await db.query.expenseReceipts.findFirst({
where: eq(expenseReceipts.id, id),
with: { expense: true },
});
if (receipt?.expense.createdById !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const body = await getObject(receipt.storageKey);
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": receipt.mimeType,
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
"Cache-Control": "private, max-age=3600",
},
});
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
}
+3 -210
View File
@@ -1,213 +1,6 @@
"use client"; import { env } from "~/env";
import { RegisterForm } from "./register-form";
import { useState, Suspense } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { Mail, Lock, ArrowRight, User } from "lucide-react";
function formatAuthError(message: string | undefined, fallback: string): string {
if (!message || message === "Required") {
return fallback;
}
return message;
}
function RegisterForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const trimmedFirstName = String(formData.get("firstName") ?? "").trim();
const trimmedLastName = String(formData.get("lastName") ?? "").trim();
const trimmedEmail = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
toast.error("Please enter your first name, last name, and email.");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: trimmedFirstName,
lastName: trimmedLastName,
email: trimmedEmail,
password,
}),
});
let data: { error?: string; signInRequired?: boolean } = {};
try {
data = (await res.json()) as typeof data;
} catch {
toast.error("Registration failed. Please try again.");
return;
}
if (!res.ok) {
toast.error(
formatAuthError(data.error, "Registration failed. Please check the form."),
);
return;
}
if (data.signInRequired) {
toast.success("Account created! Please sign in.");
router.push("/auth/signin");
return;
}
toast.success("Account created successfully!");
router.push("/dashboard");
router.refresh();
} catch {
toast.error("Registration failed. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="mx-auto w-full max-w-md border-border/50 bg-background/80 backdrop-blur-xl">
<CardContent className="p-8">
<div className="space-y-6">
<div className="space-y-2">
<Logo size="lg" />
<div>
<h1 className="font-heading text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm">Get started today</p>
</div>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
required
autoFocus
autoComplete="given-name"
className="h-10 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
required
autoComplete="family-name"
className="h-10 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="h-10 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
className="h-10 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</div>
<Button type="submit" className="h-10 w-full" disabled={loading}>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2" />
<span>Creating account</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Create Account</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<p className="text-muted-foreground text-center text-sm">
Already have an account?{" "}
<a href="/auth/signin" className="text-foreground font-medium hover:underline">
Sign in
</a>
</p>
<LegalAgreementNotice action="creating an account" />
</div>
</CardContent>
</Card>
</div>
);
}
export default function RegisterPage() { export default function RegisterPage() {
return ( return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
<Suspense fallback={<div>Loading...</div>}>
<RegisterForm />
</Suspense>
);
} }
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Lock, Mail, User, UserX } from "lucide-react";
import {
AuthCard,
AuthCardHeader,
AuthPageShell,
} from "~/components/auth/auth-page-shell";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
function formatAuthError(message: string | undefined, fallback: string): string {
if (!message || message === "Required") {
return fallback;
}
return message;
}
interface RegisterFormProps {
signupsDisabled?: boolean;
}
export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
const trimmedFirstName = firstName.trim();
const trimmedLastName = lastName.trim();
const trimmedEmail = email.trim();
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
toast.error("Please enter your first name, last name, and email.");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: trimmedFirstName,
lastName: trimmedLastName,
email: trimmedEmail,
password,
}),
});
let data: { error?: string; signInRequired?: boolean } = {};
try {
data = (await res.json()) as typeof data;
} catch {
toast.error("Registration failed. Please try again.");
return;
}
if (!res.ok) {
toast.error(
formatAuthError(data.error, "Registration failed. Please check the form."),
);
return;
}
if (data.signInRequired) {
toast.success("Account created! Please sign in.");
router.push("/auth/signin");
return;
}
toast.success("Account created successfully!");
router.push("/dashboard");
router.refresh();
} catch {
toast.error("Registration failed. Please try again.");
} finally {
setLoading(false);
}
}
if (signupsDisabled) {
return (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Registration closed"
description="New account sign-ups are not available right now"
/>
<div className="bg-muted/50 text-muted-foreground mb-6 flex gap-3 rounded-xl border px-4 py-3 text-sm">
<UserX className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<p>
This workspace is not accepting new registrations. If you already
have an account, sign in below. Contact your administrator if you
need access.
</p>
</div>
<Button asChild className="h-11 w-full">
<Link href="/auth/signin">
Sign in to your account
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</AuthCard>
</AuthPageShell>
);
}
return (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Create your account"
description="Get started with your workspace"
/>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
autoComplete="given-name"
className="h-11 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
autoComplete="family-name"
className="h-11 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="h-11 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="h-11 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? "Creating account…" : "Create account"}
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</form>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/auth/signin"
className="text-foreground font-medium hover:underline"
>
Sign in
</Link>
</p>
<LegalAgreementNotice action="creating an account" className="mt-5" />
</AuthCard>
</AuthPageShell>
);
}
+14 -7
View File
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { env } from "~/env"; import { env } from "~/env";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { safeCallbackPath } from "~/lib/safe-callback-url";
import { toast } from "sonner"; import { toast } from "sonner";
interface SignInFormProps { interface SignInFormProps {
@@ -25,8 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -40,10 +40,17 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
setLoading(false); setLoading(false);
if (error) { if (error) {
const message = error.message?.toLowerCase() ?? "";
const rateLimited =
error.status === 429 ||
message.includes("too many") ||
message.includes("rate limit");
toast.error( toast.error(
error.message && error.message !== "Required" rateLimited
? error.message ? "Too many sign-in attempts. Please wait a moment and try again."
: "Invalid email or password", : error.message && error.message !== "Required"
? error.message
: "Invalid email or password",
); );
return; return;
} }
@@ -74,7 +81,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
description="Sign in to your workspace" description="Sign in to your workspace"
/> />
{signupDisabled && ( {!allowRegistration && (
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm"> <p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
New account registration is currently disabled. New account registration is currently disabled.
</p> </p>
@@ -155,7 +162,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
</Button> </Button>
</form> </form>
{allowRegistration && !signupDisabled && ( {allowRegistration && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
@@ -7,13 +7,37 @@ import { Card, CardContent } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Square, Clock } from "lucide-react"; import { Square, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock"; import {
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
} from "~/lib/time-clock";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
export function ActiveTimerWidget() { interface ActiveTimerWidgetProps {
collapsed?: boolean;
compact?: boolean;
}
export function ActiveTimerWidget({
collapsed = false,
compact = false,
}: ActiveTimerWidgetProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, { const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
refetchInterval: 30_000, undefined,
}); {
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchInterval: 60_000,
},
);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -56,9 +80,6 @@ export function ActiveTimerWidget() {
} }
void utils.timeEntries.getRunning.invalidate(); void utils.timeEntries.getRunning.invalidate();
void utils.timeEntries.getAll.invalidate();
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
@@ -69,64 +90,153 @@ export function ActiveTimerWidget() {
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` ? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null; : null;
const description = formatRunningTimerLabel(running.description);
const renderStopButton = (className?: string) => (
<Button
variant="destructive"
size="sm"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
className={cn(compact && "h-8 px-2", className)}
>
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} />
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
</Button>
);
if (compact) {
return (
<div className="ml-auto flex min-w-0 items-center gap-1.5">
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1"
>
<span className="relative flex h-2 w-2 shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
<span className="text-primary truncate font-mono text-sm font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
</Link>
{renderStopButton("shrink-0")}
</div>
);
}
if (collapsed) {
return (
<div className="flex justify-center">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10"
>
<Clock className="text-primary h-5 w-5" />
<span className="absolute top-1 right-1 flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
>
<p className="text-sm font-medium">
{description}
{running.client && (
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-primary font-mono text-lg font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</p>
{invoiceLabel ? (
<p className="text-muted-foreground text-xs">
Billing to{" "}
<Link
href={`/dashboard/invoices/${running.invoice!.id}`}
className="text-primary hover:underline"
>
{invoiceLabel}
</Link>
</p>
) : (
<p className="text-muted-foreground text-xs">No invoice selected</p>
)}
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
<Link href="/dashboard/time-clock">Open</Link>
</Button>
{renderStopButton()}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
return ( return (
<Card className="border-primary/30 bg-primary/5"> <Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center"> <CardContent className="flex flex-col gap-3 p-3">
<span className="relative flex h-3 w-3 flex-shrink-0"> <div className="flex items-start gap-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" /> <span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" /> <span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
</span> <span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium"> <p className="text-sm leading-snug font-medium">
{running.description || ( {description}
<span className="text-muted-foreground italic">No description</span> {running.client && (
)} <span className="text-muted-foreground font-normal">
{running.client && ( {" "}
<span className="text-muted-foreground font-normal"> · {running.client.name}</span> · {running.client.name}
)} </span>
</p> )}
<p className="text-muted-foreground text-xs"> </p>
{invoiceLabel ? ( <p className="text-muted-foreground mt-1 text-xs leading-snug">
<> {invoiceLabel ? (
Billing to{" "} <>
<Link Billing to{" "}
href={`/dashboard/invoices/${running.invoice!.id}`} <Link
className="text-primary hover:underline" href={`/dashboard/invoices/${running.invoice!.id}`}
> className="text-primary hover:underline"
{invoiceLabel} >
</Link> {invoiceLabel}
</> </Link>
) : ( </>
<>No invoice selected open time clock to assign</> ) : (
)} <>No invoice selected open time clock to assign</>
{" · "} )}
<Link href="/dashboard/time-clock" className="text-primary hover:underline"> {" · "}
Time clock <Link href="/dashboard/time-clock" className="text-primary hover:underline">
</Link> Time clock
</p> </Link>
</p>
</div>
</div> </div>
<span className="text-primary font-mono text-2xl font-bold tabular-nums"> <div className="flex flex-col items-center gap-2">
{formatElapsedSeconds(elapsed)} <span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
</span> {formatElapsedSeconds(elapsed)}
</span>
<div className="flex gap-2"> <div className="flex w-full flex-col gap-1.5">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild className="h-8 w-full">
<Link href="/dashboard/time-clock"> <Link href="/dashboard/time-clock">
<Clock className="mr-1.5 h-3.5 w-3.5" /> <Clock className="mr-1 h-3.5 w-3.5" />
Open Open
</Link> </Link>
</Button> </Button>
<Button {renderStopButton("w-full")}
variant="destructive" </div>
size="sm"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
>
<Square className="mr-1.5 h-3.5 w-3.5" />
{clockOut.isPending ? "Stopping…" : "Stop"}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -8,7 +8,14 @@ import {
Clock, Clock,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent } from "~/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { cn } from "~/lib/utils";
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown"; type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
@@ -51,41 +58,36 @@ export function AnimatedStatsCard({
const isPositive = trend === "up"; const isPositive = trend === "up";
const isNeutral = trend === "neutral"; const isNeutral = trend === "neutral";
// For now, always use the formatted value prop to ensure correct display
// Animation can be added back once the basic display is working correctly
const displayValue = value;
// Suppress unused parameter warnings for now
void delay; void delay;
void isCurrency; void isCurrency;
void numericValue; void numericValue;
return ( return (
<Card> <Card>
<CardContent className="p-6"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center justify-between space-y-0 pb-2"> <CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
<div className="flex items-center space-x-2"> <Icon className="h-4 w-4" />
<Icon className="text-muted-foreground h-5 w-5" /> {title}
<p className="text-muted-foreground text-sm font-medium">{title}</p> </CardTitle>
</div> <div
<div className={cn(
className="flex items-center space-x-1 text-xs" "flex items-center gap-1 text-xs font-medium",
style={{ isNeutral
color: isNeutral ? "text-muted-foreground"
? "hsl(var(--muted-foreground))" : isPositive
: isPositive ? "text-emerald-600 dark:text-emerald-400"
? "oklch(var(--chart-2))" : "text-amber-600 dark:text-amber-400",
: "oklch(var(--chart-3))", )}
}} >
> <TrendIcon className="h-3 w-3" />
<TrendIcon className="h-3 w-3" /> <span className="font-mono tabular-nums">{change}</span>
<span>{change}</span>
</div>
</div>
<div className="space-y-1">
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
<p className="text-muted-foreground text-xs">{description}</p>
</div> </div>
</CardHeader>
<CardContent className="pt-0">
<p className="font-mono text-2xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<CardDescription className="mt-1">{description}</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1,19 +1,18 @@
"use client"; "use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Cell, Pie, PieChart, Tooltip } from "recharts";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
interface Invoice { export interface StatusChartDatum {
id: string;
totalAmount: number;
status: string; status: string;
dueDate: Date | string; name: string;
count: number;
value: number;
} }
interface InvoiceStatusChartProps { interface InvoiceStatusChartProps {
invoices: Invoice[]; data: StatusChartDatum[];
} }
const STATUS_COLORS = { const STATUS_COLORS = {
@@ -47,52 +46,26 @@ function StatusTooltip({
return ( return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg"> <div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p> <p className="font-medium">{data.name}</p>
<p className="text-sm"> <p className="font-mono text-sm tabular-nums">
{data.count} invoice{data.count !== 1 ? "s" : ""} {data.count} invoice{data.count !== 1 ? "s" : ""}
</p> </p>
<p className="text-sm">{formatChartCurrency(data.value)}</p> <p className="font-mono text-sm tabular-nums">
{formatChartCurrency(data.value)}
</p>
</div> </div>
); );
} }
return null; return null;
} }
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) {
// Process invoice data to create status breakdown
const statusData = invoices.reduce(
(acc, invoice) => {
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
acc[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
acc[effectiveStatus].count += 1;
acc[effectiveStatus].value += invoice.totalAmount;
return acc;
},
{} as Record<string, { status: string; count: number; value: number }>,
);
const chartData = Object.values(statusData).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
const pieAnimationDuration = Math.round( const pieAnimationDuration = Math.round(
600 / (animationSpeedMultiplier || 1), 600 / (animationSpeedMultiplier || 1),
); );
if (chartData.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="h-48 w-full"> <ResponsiveChart height={192} className="h-48">
<ResponsiveContainer width="100%" height="100%"> <PieChart>
<PieChart>
<Pie <Pie
data={chartData} data={data}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={40} innerRadius={40}
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
animationDuration={pieAnimationDuration} animationDuration={pieAnimationDuration}
animationEasing="ease-out" animationEasing="ease-out"
> >
{chartData.map((entry, index) => ( {data.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={ fill={
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
</Pie> </Pie>
<Tooltip content={<StatusTooltip />} /> <Tooltip content={<StatusTooltip />} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveChart>
</div>
{/* Legend */}
<div className="space-y-2"> <div className="space-y-2">
{chartData.map((item) => ( {data.map((item) => (
<div key={item.status} className="flex items-center justify-between"> <div key={item.status} className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<span className="text-sm font-medium">{item.name}</span> <span className="text-sm font-medium">{item.name}</span>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium">{item.count}</p> <p className="font-mono text-sm font-medium tabular-nums">
<p className="text-muted-foreground text-xs"> {item.count}
</p>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{formatChartCurrency(item.value)} {formatChartCurrency(item.value)}
</p> </p>
</div> </div>
@@ -3,25 +3,25 @@
import { import {
Bar, Bar,
BarChart, BarChart,
ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { ResponsiveChart } from "~/components/charts/responsive-chart";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice { export interface MonthlyMetricsChartDatum {
id: string; month: string;
totalAmount: number; monthLabel: string;
issueDate: Date | string; totalInvoices: number;
status: string; paidInvoices: number;
dueDate: Date | string; pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
} }
interface MonthlyMetricsChartProps { interface MonthlyMetricsChartProps {
invoices: Invoice[]; data: MonthlyMetricsChartDatum[];
} }
function MonthlyMetricsTooltip({ function MonthlyMetricsTooltip({
@@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({
}: { }: {
active?: boolean; active?: boolean;
payload?: Array<{ payload?: Array<{
payload: { payload: MonthlyMetricsChartDatum;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>; }>;
label?: string; label?: string;
}) { }) {
if (active && payload?.length) { if (active && payload?.length) {
const data = payload[0]!.payload; const chartDatum = payload[0]!.payload;
return ( return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg"> <div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p> <p className="font-medium">{label}</p>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p> <p className="text-primary font-medium font-mono tabular-nums">
<p className="text-primary/80">Pending: {data.pendingInvoices}</p> Paid: {chartDatum.paidInvoices}
<p className="text-destructive">Overdue: {data.overdueInvoices}</p> </p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p> <p className="text-primary/80 font-mono tabular-nums">
<p className="text-foreground border-t pt-1 font-medium"> Pending: {chartDatum.pendingInvoices}
Total: {data.totalInvoices} </p>
<p className="text-destructive font-mono tabular-nums">
Overdue: {chartDatum.overdueInvoices}
</p>
<p className="text-muted-foreground font-mono tabular-nums">
Draft: {chartDatum.draftInvoices}
</p>
<p className="text-foreground border-t pt-1 font-medium font-mono tabular-nums">
Total: {chartDatum.totalInvoices}
</p> </p>
</div> </div>
</div> </div>
@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({
return null; return null;
} }
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) {
// Process invoice data to create monthly metrics
const monthlyData = invoices.reduce(
(acc, invoice) => {
const date = new Date(invoice.issueDate);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
acc[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
acc[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
acc[monthKey].paidInvoices += 1;
break;
case "sent":
acc[monthKey].pendingInvoices += 1;
break;
case "overdue":
acc[monthKey].overdueInvoices += 1;
break;
case "draft":
acc[monthKey].draftInvoices += 1;
break;
}
return acc;
},
{} as Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
>,
);
// Convert to array and sort by month
const chartData = Object.values(monthlyData)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6) // Show last 6 months
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
const barAnimationDuration = Math.round( const barAnimationDuration = Math.round(
500 / (animationSpeedMultiplier || 1), 500 / (animationSpeedMultiplier || 1),
); );
if (chartData.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="h-48 w-full"> <ResponsiveChart height={192} className="h-48">
<ResponsiveContainer width="100%" height="100%"> <BarChart data={data}>
<BarChart data={chartData}>
<XAxis <XAxis
dataKey="monthLabel" dataKey="monthLabel"
axisLine={false} axisLine={false}
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} tick={{
fontSize: 12,
fill: "var(--muted-foreground)",
fontFamily: "var(--font-mono)",
}}
/> />
<Tooltip content={<MonthlyMetricsTooltip />} /> <Tooltip content={<MonthlyMetricsTooltip />} />
<Bar <Bar
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
animationEasing="ease-out" animationEasing="ease-out"
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveChart>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2"> <div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
@@ -3,11 +3,11 @@
import { import {
Area, Area,
AreaChart, AreaChart,
ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps { interface RevenueChartProps {
@@ -41,7 +41,10 @@ const CustomTooltip = ({
return ( return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg"> <div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p> <p className="font-medium">{label}</p>
<p style={{ color: "hsl(0, 0%, 60%)" }}> <p
className="font-mono tabular-nums"
style={{ color: "hsl(0, 0%, 60%)" }}
>
Revenue: {formatCurrency(data.revenue)} Revenue: {formatCurrency(data.revenue)}
</p> </p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -84,9 +87,8 @@ export function RevenueChart({ data }: RevenueChartProps) {
} }
return ( return (
<div className="h-48 w-full md:h-64"> <ResponsiveChart height={256} className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%"> <AreaChart data={chartData}>
<AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop <stop
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }} tick={{
fontSize: 12,
fill: "hsl(var(--muted-foreground))",
fontFamily: "var(--font-mono)",
}}
tickFormatter={formatCurrency} tickFormatter={formatCurrency}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
animationEasing="ease-out" animationEasing="ease-out"
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveChart>
</div>
); );
} }
@@ -1,7 +1,39 @@
"use client"; "use client";
import { Shield } from "lucide-react"; import {
Activity,
Building2,
Clock,
FileText,
KeyRound,
Pencil,
ScrollText,
Search,
Shield,
Users,
} from "lucide-react";
import { useDeferredValue, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { EmptyState } from "~/components/layout/page-layout";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { dashboardStatGridClass } from "~/components/layout/dashboard-page";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -9,6 +41,15 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -18,84 +59,563 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
export function AdministrationContent() { const PAGE_SIZE = 25;
const {
data: accounts = [], const ACTION_LABELS: Record<string, string> = {
refetch, "user.profile_updated": "Profile updated",
error, "user.role_updated": "Role updated",
} = api.settings.listAccounts.useQuery(); "user.password_reset_sent": "Password reset sent",
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({ "platform.pdf_settings_updated": "PDF settings updated",
onSuccess: () => { };
toast.success("Account role updated");
void refetch(); function formatAction(action: string) {
}, return ACTION_LABELS[action] ?? action;
onError: (mutationError: { message: string }) => { }
toast.error(`Failed to update role: ${mutationError.message}`);
}, function AdminOverview() {
}); const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
if (error) { if (error) {
return ( return (
<Card className="bg-card border-border border"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center gap-2"> <CardTitle>Platform overview</CardTitle>
<CardDescription>Unable to load statistics.</CardDescription>
</CardHeader>
</Card>
);
}
const statCards = [
{
label: "Total users",
value: stats?.totalUsers ?? 0,
icon: Users,
},
{
label: `Active (${stats?.activeUserWindowDays ?? 30}d)`,
value: stats?.activeUsers ?? 0,
icon: Activity,
},
{
label: "Administrators",
value: stats?.adminCount ?? 0,
icon: Shield,
},
{
label: "Invoices",
value: stats?.totalInvoices ?? 0,
icon: FileText,
},
{
label: "Businesses",
value: stats?.totalBusinesses ?? 0,
icon: Building2,
},
{
label: "Clients",
value: stats?.totalClients ?? 0,
icon: Users,
},
{
label: "Time entries",
value: stats?.totalTimeEntries ?? 0,
icon: Clock,
},
];
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" /> <Shield className="text-primary h-5 w-5" />
Administration Platform overview
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Administrative access is required for this page. Aggregate counts only no customer data, credentials, or bulk PII.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent>
{isLoading ? (
<p className="text-muted-foreground text-sm">Loading statistics</p>
) : (
<div className={dashboardStatGridClass}>
{statCards.map((stat) => (
<Card key={stat.label}>
<CardContent className="p-4">
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
<stat.icon className="h-3.5 w-3.5" />
{stat.label}
</div>
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
type EditUserState = {
id: string;
name: string;
email: string;
role: "user" | "admin";
};
function AdminUsers() {
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const [offset, setOffset] = useState(0);
const [editUser, setEditUser] = useState<EditUserState | null>(null);
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetUserName, setResetUserName] = useState("");
const utils = api.useUtils();
const { data, isLoading, error, isFetching } = api.admin.listUsers.useQuery({
search: deferredSearch || undefined,
offset,
limit: PAGE_SIZE,
});
const updateUserMutation = api.admin.updateUser.useMutation({
onSuccess: () => {
toast.success("User updated");
setEditUser(null);
void utils.admin.listUsers.invalidate();
void utils.admin.listAuditLog.invalidate();
},
onError: (mutationError) => {
toast.error(mutationError.message);
},
});
const sendResetMutation = api.admin.sendPasswordReset.useMutation({
onSuccess: (result) => {
if (result.emailSent) {
toast.success("Password reset email sent");
} else {
toast.warning(
"Reset token created, but email could not be sent. Check Resend configuration.",
);
}
setResetUserId(null);
void utils.admin.listAuditLog.invalidate();
},
onError: (mutationError) => {
toast.error(mutationError.message);
},
});
const users = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>Administrative access is required.</CardDescription>
</CardHeader>
</Card> </Card>
); );
} }
return ( return (
<Card className="bg-card border-border border"> <>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Users
</CardTitle>
<CardDescription>
Search accounts, edit profiles, and manage access.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative max-w-md">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setOffset(0);
}}
placeholder="Search by name or email…"
className="pl-9"
/>
</div>
{isLoading ? (
<p className="text-muted-foreground text-sm">Loading users</p>
) : users.length === 0 ? (
<EmptyState
icon={<Users className="h-6 w-6" />}
title="No users found"
description={
deferredSearch
? "Try a different search term."
: "No accounts have been created yet."
}
/>
) : (
<div className="divide-border divide-y border">
{users.map((user) => (
<div
key={user.id}
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">{user.name}</p>
<Badge
variant={user.role === "admin" ? "default" : "secondary"}
>
{user.role}
</Badge>
{user.emailVerified ? (
<Badge variant="outline" className="text-xs">
Verified
</Badge>
) : null}
</div>
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Joined{" "}
{new Date(user.createdAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
<div className="flex flex-shrink-0 gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setEditUser({
id: user.id,
name: user.name,
email: user.email,
role: user.role as "user" | "admin",
})
}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setResetUserId(user.id);
setResetUserName(user.name);
}}
>
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
Reset password
</Button>
</div>
</div>
))}
</div>
)}
{total > PAGE_SIZE ? (
<div className="flex items-center justify-between pt-2">
<p className="text-muted-foreground text-xs">
Page {currentPage} of {totalPages} · {total} users
{isFetching ? " · Updating…" : ""}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset((value) => value + PAGE_SIZE)}
>
Next
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
<Dialog
open={editUser != null}
onOpenChange={(open) => {
if (!open) setEditUser(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit user</DialogTitle>
</DialogHeader>
{editUser ? (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
updateUserMutation.mutate({
userId: editUser.id,
name: editUser.name,
email: editUser.email,
role: editUser.role,
});
}}
>
<div className="space-y-2">
<Label htmlFor="edit-user-name">Name</Label>
<Input
id="edit-user-name"
value={editUser.name}
onChange={(event) =>
setEditUser((current) =>
current
? { ...current, name: event.target.value }
: current,
)
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-user-email">Email</Label>
<Input
id="edit-user-email"
type="email"
value={editUser.email}
onChange={(event) =>
setEditUser((current) =>
current
? { ...current, email: event.target.value }
: current,
)
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-user-role">Role</Label>
<Select
value={editUser.role}
onValueChange={(role) =>
setEditUser((current) =>
current
? { ...current, role: role as "user" | "admin" }
: current,
)
}
>
<SelectTrigger id="edit-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setEditUser(null)}
>
Cancel
</Button>
<Button type="submit" disabled={updateUserMutation.isPending}>
{updateUserMutation.isPending ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={resetUserId != null}
onOpenChange={(open) => {
if (!open) setResetUserId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send password reset?</AlertDialogTitle>
<AlertDialogDescription>
A password reset email will be sent to{" "}
<span className="font-medium">{resetUserName}</span>. The link
expires in 24 hours.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={sendResetMutation.isPending}
onClick={() => {
if (resetUserId) {
sendResetMutation.mutate({ userId: resetUserId });
}
}}
>
{sendResetMutation.isPending ? "Sending…" : "Send reset email"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function AdminAuditLog() {
const [offset, setOffset] = useState(0);
const { data, isLoading, error, isFetching } = api.admin.listAuditLog.useQuery(
{
offset,
limit: PAGE_SIZE,
},
);
const entries = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Audit log</CardTitle>
<CardDescription>Administrative access is required.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" /> <ScrollText className="text-primary h-5 w-5" />
Accounts Audit log
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Manage account access and roles without opening customer data. Recent administrative actions across the platform.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent>
{accounts.map((account) => ( {isLoading ? (
<div <p className="text-muted-foreground text-sm">Loading audit log</p>
key={account.id} ) : entries.length === 0 ? (
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between" <EmptyState
> icon={<ScrollText className="h-6 w-6" />}
<div className="min-w-0"> title="No audit events yet"
<p className="text-sm font-medium">{account.name}</p> description="Administrative actions will appear here."
<p className="text-muted-foreground truncate text-xs"> />
{account.email} ) : (
</p> <div className="divide-border divide-y border">
<p className="text-muted-foreground mt-1 text-xs"> {entries.map((entry) => (
Created {new Date(account.createdAt).toLocaleDateString()} <div key={entry.id} className="space-y-1 p-4">
</p> <div className="flex flex-wrap items-center gap-2">
</div> <p className="text-sm font-medium">
<Select {formatAction(entry.action)}
value={account.role} </p>
onValueChange={(role) => <Badge variant="outline" className="text-xs">
updateAccountRoleMutation.mutate({ {entry.targetType}
userId: account.id, </Badge>
role: role as "user" | "admin", </div>
}) <p className="text-muted-foreground text-xs">
} {entry.actor?.name ?? "Unknown admin"} ·{" "}
> {new Date(entry.createdAt).toLocaleString(undefined, {
<SelectTrigger className="w-full sm:w-36"> month: "short",
<SelectValue /> day: "numeric",
</SelectTrigger> year: "numeric",
<SelectContent> hour: "numeric",
<SelectItem value="user">User</SelectItem> minute: "2-digit",
<SelectItem value="admin">Admin</SelectItem> })}
</SelectContent> {entry.targetId ? ` · target ${entry.targetId.slice(0, 8)}` : ""}
</Select> </p>
{entry.metadata &&
Object.keys(entry.metadata).length > 0 ? (
<p className="text-muted-foreground font-mono text-xs break-all">
{JSON.stringify(entry.metadata)}
</p>
) : null}
</div>
))}
</div> </div>
))} )}
{total > PAGE_SIZE ? (
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-xs">
Page {currentPage} of {totalPages} · {total} events
{isFetching ? " · Updating…" : ""}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset((value) => value + PAGE_SIZE)}
>
Next
</Button>
</div>
</div>
) : null}
</CardContent> </CardContent>
</Card> </Card>
); );
} }
export function AdministrationContent() {
return (
<PageTabs defaultValue="overview">
<PageTabsList>
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
<PageTabsTrigger value="users">Users</PageTabsTrigger>
<PageTabsTrigger value="audit">Audit log</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="overview">
<AdminOverview />
</PageTabsContent>
<PageTabsContent value="users">
<AdminUsers />
</PageTabsContent>
<PageTabsContent value="audit">
<AdminAuditLog />
</PageTabsContent>
</PageTabs>
);
}
+24 -6
View File
@@ -1,16 +1,34 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { HydrateClient } from "~/trpc/server"; import { HydrateClient } from "~/trpc/server";
import { AdministrationContent } from "./_components/administration-content"; import { AdministrationContent } from "./_components/administration-content";
export default async function AdministrationPage() { export default async function AdministrationPage() {
const session = await getOptionalServerSessionFromHeaders();
if (session?.user) {
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
redirect("/dashboard");
}
}
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Administration" title="Administration"
description="Manage account access and platform administration" description="Platform statistics, user management, and audit logging"
variant="gradient"
/> />
<HydrateClient> <HydrateClient>
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
<AdministrationContent /> <AdministrationContent />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+13 -8
View File
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -43,11 +49,10 @@ export default async function BusinessDetailPage({
}; };
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`} title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information" description="View business details and information"
variant="gradient"
> >
<Button asChild variant="outline" className="shadow-sm"> <Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=businesses"> <Link href="/dashboard/entities?tab=businesses">
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
<span>Edit Business</span> <span>Edit Business</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Business Information Card */} {/* Business Information Card */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
</div> </div>
{/* Settings & Actions Card */} {/* Settings & Actions Card */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
</Card> </Card>
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table"; import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react"; import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
Dialog, Dialog,
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
data={searchableBusinesses} data={searchableBusinesses}
searchKey="searchValue" searchKey="searchValue"
searchPlaceholder="Search by name or nickname..." searchPlaceholder="Search by name or nickname..."
emptyTitle="Create your first business"
emptyDescription="Set up a business profile for invoices, branding, and tax details."
emptyIcon={<Building className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/businesses/new">
<Plus className="mr-2 h-4 w-4" />
Add business
</Link>
</Button>
}
onRowClick={handleRowClick} onRowClick={handleRowClick}
/> />
+13 -8
View File
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import Link from "next/link"; import Link from "next/link";
import { import {
Edit, Edit,
@@ -57,11 +63,10 @@ export default async function ClientDetailPage({
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0; client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={client.name} title={client.name}
description="View client details and information" description="View client details and information"
variant="gradient"
> >
<Button asChild variant="outline" className="shadow-sm"> <Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=clients"> <Link href="/dashboard/entities?tab=clients">
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
<span>Edit Client</span> <span>Edit Client</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Client Information Card */} {/* Client Information Card */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
</div> </div>
{/* Stats Card */} {/* Stats Card */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
)} )}
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table"; import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { UserPlus, Pencil, Trash2 } from "lucide-react"; import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
Dialog, Dialog,
@@ -179,6 +179,17 @@ export function ClientsDataTable({
data={clients} data={clients}
searchKey="name" searchKey="name"
searchPlaceholder="Search clients..." searchPlaceholder="Search clients..."
emptyTitle="Create your first client"
emptyDescription="Add clients to bill them and keep contact details in one place."
emptyIcon={<Users className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-4 w-4" />
Add client
</Link>
</Button>
}
onRowClick={handleRowClick} onRowClick={handleRowClick}
/> />
@@ -3,15 +3,32 @@
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { ClientsDataTable } from "../../clients/_components/clients-data-table";
import { ClientsTable } from "../../clients/_components/clients-table"; import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
import { BusinessesTable } from "../../businesses/_components/businesses-table"; import type { RouterOutputs } from "~/trpc/react";
type EntityTab = "clients" | "businesses"; type EntityTab = "clients" | "businesses";
export function EntitiesView({ initialTab }: { initialTab: EntityTab }) { type Client = RouterOutputs["clients"]["getAll"][number];
type Business = RouterOutputs["businesses"]["getAll"][number];
export function EntitiesView({
initialTab,
clients,
businesses,
}: {
initialTab: EntityTab;
clients: Client[];
businesses: Business[];
}) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tab: EntityTab = const tab: EntityTab =
@@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
const addLabel = tab === "clients" ? "Add client" : "Add business"; const addLabel = tab === "clients" ? "Add client" : "Add business";
return ( return (
<div className="space-y-6"> <>
<PageHeader <DashboardPageHeader
title="Entities" title="Entities"
description="Clients you bill and businesses you send from" description="Clients you bill and businesses you send from"
variant="gradient"
> >
<Button asChild variant="default" className="hover-lift shadow-md"> <Button asChild variant="default" className="hover-lift shadow-md">
<Link href={addHref}> <Link href={addHref}>
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
<span>{addLabel}</span> <span>{addLabel}</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<Tabs value={tab} onValueChange={handleTabChange}> <PageTabs value={tab} onValueChange={handleTabChange}>
<TabsList className="grid w-full max-w-md grid-cols-2"> <PageTabsList>
<TabsTrigger value="clients">Clients</TabsTrigger> <PageTabsTrigger value="clients">Clients</PageTabsTrigger>
<TabsTrigger value="businesses">Businesses</TabsTrigger> <PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
</TabsList> </PageTabsList>
<TabsContent value="clients" className="mt-6"> <PageTabsContent value="clients">
<ClientsTable /> {tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
</TabsContent> </PageTabsContent>
<TabsContent value="businesses" className="mt-6"> <PageTabsContent value="businesses">
<BusinessesTable /> {tab === "businesses" ? (
</TabsContent> <BusinessesDataTable businesses={businesses} />
</Tabs> ) : null}
</div> </PageTabsContent>
</PageTabs>
</>
); );
} }
+13 -12
View File
@@ -1,6 +1,5 @@
import { Suspense } from "react"; import { api } from "~/trpc/server";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DashboardPage } from "~/components/layout/dashboard-page";
import { api, HydrateClient } from "~/trpc/server";
import { EntitiesView } from "./_components/entities-view"; import { EntitiesView } from "./_components/entities-view";
export default async function EntitiesPage({ export default async function EntitiesPage({
@@ -11,16 +10,18 @@ export default async function EntitiesPage({
const params = await searchParams; const params = await searchParams;
const initialTab = params.tab === "businesses" ? "businesses" : "clients"; const initialTab = params.tab === "businesses" ? "businesses" : "clients";
void api.clients.getAll.prefetch(); const [clients, businesses] = await Promise.all([
void api.businesses.getAll.prefetch(); api.clients.getAll(),
api.businesses.getAll(),
]);
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<HydrateClient> <EntitiesView
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}> initialTab={initialTab}
<EntitiesView initialTab={initialTab} /> clients={clients}
</Suspense> businesses={businesses}
</HydrateClient> />
</div> </DashboardPage>
); );
} }
File diff suppressed because it is too large Load Diff
@@ -1,25 +1,27 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
export function InvoiceDetailsSkeleton() { export function InvoiceDetailsSkeleton() {
return ( return (
<div className="space-y-6 pb-24"> <DashboardPage className="pb-24">
{/* Header */} <DashboardPageHeader
<PageHeader
title="Loading..." title="Loading..."
description="View and manage invoice information" description="View and manage invoice information"
variant="gradient"
> >
<Skeleton className="h-10 w-10 sm:w-32" /> <Skeleton className="h-10 w-10 sm:w-32" />
<Skeleton className="h-10 w-24" /> <Skeleton className="h-10 w-24" />
</PageHeader> </DashboardPageHeader>
{/* Content */} <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Skeleton */} {/* Invoice Header Skeleton */}
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <CardContent className="p-4 sm:p-6">
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
</div> </div>
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="lg:sticky lg:top-6"> <Card className="lg:sticky lg:top-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
</Card> </Card>
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -2,6 +2,10 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "~/components/data/data-table"; import { DataTable } from "~/components/data/data-table";
import {
formatLineItemDetail,
isFixedLineItem,
} from "~/lib/invoice-line-item";
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
<div className="sm:hidden"> <div className="sm:hidden">
<p className="font-medium">{item.description}</p> <p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @{" "} {formatDate(item.date)} &middot;{" "}
{formatCurrency(item.rate)}/hr {formatLineItemDetail(item.hours, item.rate, formatCurrency)}
</p> </p>
</div> </div>
</> </>
@@ -69,9 +73,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
{ {
accessorKey: "hours", accessorKey: "hours",
header: "Hours", header: "Hours",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="text-right">{row.getValue("hours")}</div> const hours = row.getValue<number>("hours");
), return (
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
);
},
meta: { meta: {
headerClassName: "hidden sm:table-cell", headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell",
@@ -80,9 +87,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
{ {
accessorKey: "rate", accessorKey: "rate",
header: "Rate", header: "Rate",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div> const item = row.original;
), return (
<div className="text-right">
{isFixedLineItem(item.hours)
? "—"
: `${formatCurrency(item.rate)}/hr`}
</div>
);
},
meta: { meta: {
headerClassName: "hidden sm:table-cell", headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell",
@@ -1,9 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel"; import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
import { Button } from "~/components/ui/button";
import { ExternalLink } from "lucide-react";
interface InvoiceTimerCardProps { interface InvoiceTimerCardProps {
invoiceId: string; invoiceId: string;
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) { export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
return ( return (
<div className="space-y-3"> <TimeClockPanel
<TimeClockPanel compact
compact defaultClientId={clientId}
defaultClientId={clientId} defaultInvoiceId={invoiceId}
defaultInvoiceId={invoiceId} />
/>
<Button variant="outline" size="sm" className="w-full" asChild>
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
Open full time clock
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</Button>
</div>
); );
} }
@@ -25,7 +25,7 @@ export function PDFDownloadButton({
{ id: invoiceId }, { id: invoiceId },
{ enabled: false }, { enabled: false },
); );
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, { const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -59,11 +59,13 @@ export function PDFDownloadButton({
}; };
await generateInvoicePDF(pdfData, { await generateInvoicePDF(pdfData, {
pdfTemplate: platformTheme?.pdfTemplate, pdfTemplate: pdfSettings?.pdfTemplate,
pdfAccentColor: platformTheme?.pdfAccentColor, pdfAccentColor: pdfSettings?.pdfAccentColor,
pdfFooterText: platformTheme?.pdfFooterText, pdfFontFamily: pdfSettings?.pdfFontFamily,
pdfShowLogo: platformTheme?.pdfShowLogo, pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers, pdfFooterText: pdfSettings?.pdfFooterText,
pdfShowLogo: pdfSettings?.pdfShowLogo,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
}); });
toast.success("PDF downloaded successfully"); toast.success("PDF downloaded successfully");
} catch (error) { } catch (error) {
+17 -7
View File
@@ -1,12 +1,22 @@
"use client"; import { redirect } from "next/navigation";
import { useParams } from "next/navigation";
import InvoiceForm from "~/components/forms/invoice-form"; import InvoiceForm from "~/components/forms/invoice-form";
import { api } from "~/trpc/server";
export default function InvoiceFormPage() { interface EditInvoicePageProps {
const params = useParams(); params: Promise<{ id: string }>;
const id = params.id as string; }
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
const { id } = await params;
try {
const invoice = await api.invoices.getById({ id });
if (invoice.status !== "draft") {
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
}
} catch {
redirect("/dashboard/invoices");
}
// Pass the actual id, let the form component handle the logic
return <InvoiceForm invoiceId={id} />; return <InvoiceForm invoiceId={id} />;
} }
+39 -22
View File
@@ -20,11 +20,17 @@ import {
User, User,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound, useParams, useRouter } from "next/navigation"; import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { PageHeader } from "~/components/layout/page-header"; import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -83,6 +89,7 @@ function daysSince(date: Date) {
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false); const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false); const [reminderOpen, setReminderOpen] = useState(false);
@@ -100,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
api.payments.getByInvoice.useQuery({ invoiceId }); api.payments.getByInvoice.useQuery({ invoiceId });
const utils = api.useUtils(); const utils = api.useUtils();
useEffect(() => {
if (searchParams.get("editBlocked") === "1") {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [searchParams, invoiceId, router]);
const invalidate = () => { const invalidate = () => {
void utils.invoices.getById.invalidate({ id: invoiceId }); void utils.invoices.getById.invalidate({ id: invoiceId });
void utils.payments.getByInvoice.invalidate({ invoiceId }); void utils.payments.getByInvoice.invalidate({ invoiceId });
@@ -224,24 +238,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}; };
return ( return (
<div className="page-enter space-y-6 pb-24"> <DashboardPage className="pb-24">
<PageHeader <DashboardPageHeader
title="Invoice Details" title="Invoice Details"
description="View and manage invoice information" description="View and manage invoice information"
variant="gradient"
> >
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" /> <PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
<Button asChild variant="default" className="hover-lift"> {storedStatus === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Button asChild variant="default" className="hover-lift">
<Edit className="mr-2 h-5 w-5" /> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
Edit <Edit className="mr-2 h-5 w-5" />
</Link> Edit
</Button> </Link>
</PageHeader> </Button>
) : null}
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Left Column */} {/* Left Column */}
<div className="space-y-6 lg:col-span-2"> <div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Invoice Header */} {/* Invoice Header */}
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <CardContent className="p-4 sm:p-6">
@@ -531,7 +546,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div> </div>
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
{storedStatus === "draft" && ( {storedStatus === "draft" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} /> <InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
)} )}
@@ -544,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Button asChild variant="secondary" className="w-full"> {storedStatus === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Button asChild variant="secondary" className="w-full">
<Edit className="mr-2 h-4 w-4" /> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
Edit Invoice <Edit className="mr-2 h-4 w-4" />
</Link> Edit Invoice
</Button> </Link>
</Button>
) : null}
{invoice.items && invoice.client && ( {invoice.items && invoice.client && (
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" /> <PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
@@ -833,7 +850,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+43 -35
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -17,7 +16,20 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { cn } from "~/lib/utils";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { EmailComposer } from "~/components/forms/email-composer"; import { EmailComposer } from "~/components/forms/email-composer";
import { EmailPreview } from "~/components/forms/email-preview"; import { EmailPreview } from "~/components/forms/email-preview";
@@ -36,21 +48,20 @@ import {
function SendEmailPageSkeleton() { function SendEmailPageSkeleton() {
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title="Loading..." title="Loading..."
description="Loading invoice email" description="Loading invoice email"
variant="gradient"
/> />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="space-y-6 lg:col-span-2"> <div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-96 animate-pulse" /> <div className="bg-muted h-96 animate-pulse" />
</div> </div>
<div className="space-y-6"> <div className={cn(dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-64 animate-pulse" /> <div className="bg-muted h-64 animate-pulse" />
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -280,7 +291,7 @@ export default function SendEmailPage() {
} }
}; };
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com"; const fromEmail = invoice?.business?.email ?? NOREPLY_EMAIL;
const toEmail = invoice?.client?.email ?? ""; const toEmail = invoice?.client?.email ?? "";
const canSend = const canSend =
@@ -292,18 +303,18 @@ export default function SendEmailPage() {
if (!invoice) { if (!invoice) {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription> <AlertDescription>Invoice not found.</AlertDescription>
</Alert> </Alert>
</div> </DashboardPage>
); );
} }
return ( return (
<div className="page-enter space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={`Send Invoice ${invoice.invoiceNumber}`} title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat( description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
"en-US", "en-US",
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
day: "numeric", day: "numeric",
}, },
).format(new Date())}`} ).format(new Date())}`}
variant="gradient"
> >
<Button <Button
variant="outline" variant="outline"
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Invoice Back to Invoice
</Button> </Button>
</PageHeader> </DashboardPageHeader>
{/* Warning for missing email */} {/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && ( {(!toEmail || toEmail.trim() === "") && (
@@ -336,23 +346,22 @@ export default function SendEmailPage() {
)} )}
{/* Main Content */} {/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Tabs value={activeTab} onValueChange={setActiveTab}> <PageTabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2"> <PageTabsList>
<TabsTrigger value="compose" className="flex items-center gap-2"> <PageTabsTrigger value="compose" className="gap-2">
<Edit3 className="h-4 w-4" /> <Edit3 className="h-4 w-4" />
Compose Compose
</TabsTrigger> </PageTabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2"> <PageTabsTrigger value="preview" className="gap-2">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
Preview Preview
</TabsTrigger> </PageTabsTrigger>
</TabsList> </PageTabsList>
<div className="mt-6"> <PageTabsContent value="compose">
<TabsContent value="compose" className="space-y-6"> <Card>
<Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
@@ -387,10 +396,10 @@ export default function SendEmailPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent value="preview" className="space-y-6"> <PageTabsContent value="preview">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" /> <Eye className="h-5 w-5" />
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
</div> </PageTabs>
</Tabs>
</div> </div>
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-6"> <div className={cn(dashboardGapClass, "flex flex-col")}>
{/* Invoice Summary */} {/* Invoice Summary */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
@@ -31,6 +31,7 @@ import {
CheckCircle, CheckCircle,
Send, Send,
ChevronDown, ChevronDown,
Plus,
} from "lucide-react"; } from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> {invoice.status === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
title="Edit invoice"
>
<Edit className="h-3.5 w-3.5" />
</Button>
</Link>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="hover-scale h-8 w-8 p-0" className="hover-scale h-8 w-8 p-0"
data-action-button="true" data-action-button="true"
disabled
title="Only draft invoices can be edited"
> >
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
</Link> )}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
initialSorting={[{ id: "issueDate", desc: true }]} initialSorting={[{ id: "issueDate", desc: true }]}
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
emptyTitle="Create your first invoice"
emptyDescription="Send professional invoices and track payments from one place."
emptyIcon={<FileText className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create invoice
</Link>
</Button>
}
onRowClick={(invoice) => onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`) router.push(`/dashboard/invoices/${invoice.id}`)
} }
+3 -233
View File
@@ -1,235 +1,5 @@
import { import { redirect } from "next/navigation";
AlertCircle,
ArrowLeft,
CheckCircle,
Download,
FileSpreadsheet,
FileText,
Info,
Upload,
} from "lucide-react";
import Link from "next/link";
import { CSVImportPage } from "~/components/csv-import-page";
import { PageHeader } from "~/components/layout/page-header";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { HydrateClient } from "~/trpc/server";
// File Upload Instructions Component export default function ImportPage() {
function FormatInstructions() { redirect("/dashboard/settings?tab=data");
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Required Format */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Required CSV Format
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/50 p-4">
<p className="text-muted-foreground font-mono text-sm">
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p>
</div>
<div className="space-y-3">
<h4 className="font-semibold">Required Columns:</h4>
<div className="grid gap-2">
{[
{ field: "DATE", desc: "Date of work (M/DD/YY format)" },
{ field: "DESCRIPTION", desc: "Description of work performed" },
{ field: "HOURS", desc: "Number of hours worked" },
{ field: "RATE", desc: "Hourly rate (decimal)" },
{
field: "AMOUNT",
desc: "Total amount (calculated from hours × rate)",
},
].map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="border text-xs">{col.field}</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
</span>
</div>
))}
</div>
</div>
<div className="pt-2">
<h4 className="mb-2 font-semibold">File Naming:</h4>
<p className="text-muted-foreground text-sm">
Name your CSV files in{" "}
<code className="bg-muted rounded px-1 text-xs">
YYYY-MM-DD.csv
</code>{" "}
format for automatic date detection.
</p>
</div>
</CardContent>
</Card>
{/* Sample Data & Download */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
Sample Template
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Download our sample CSV template to see the exact format required
for importing time entries.
</p>
<div className="bg-primary/10 p-4">
<div className="flex items-start gap-3">
<Info className="text-primary mt-0.5 h-5 w-5" />
<div>
<p className="text-success text-sm font-medium">Pro Tip</p>
<p className="text-success text-sm">
The template includes sample data and formatting examples to
help you get started quickly.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs break-all">
1/15/24,&quot;Web development work&quot;,8,75.00,600.00
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// Important Notes Section
function ImportantNotes() {
return (
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertCircle className="text-primary h-5 w-5" />
Important Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="mb-2 font-semibold">Before Importing:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Use M/DD/YY format for dates (e.g., 1/15/24)</li>
<li> Ensure rates are in decimal format (e.g., 75.50)</li>
<li> File names should follow YYYY-MM-DD.csv format</li>
<li> Select a client before importing</li>
</ul>
</div>
<div>
<h4 className="mb-2 font-semibold">What Happens:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Each CSV file creates one invoice</li>
<li> Invoice dates are derived from filename</li>
<li> Invoices are created in &quot;draft&quot; status</li>
<li> You can review and edit before sending</li>
</ul>
</div>
</div>
</CardContent>
</Card>
);
}
// File Format Help Section
function FileFormatHelp() {
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileSpreadsheet className="text-primary h-5 w-5" />
Supported File Formats
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2 text-center">
<div className="bg-accent mx-auto w-fit p-3">
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">CSV Files</h4>
<p className="text-muted-foreground text-sm">
Comma-separated values from Excel, Google Sheets, or any CSV
editor
</p>
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto w-fit p-3">
<Upload className="text-primary h-6 w-6" />
</div>
<h4 className="font-semibold">Max Size</h4>
<p className="text-muted-foreground text-sm">
Up to 10MB per file with no limit on number of rows
</p>
</div>
<div className="space-y-2 text-center">
<div className="bg-secondary mx-auto w-fit p-3">
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">Validation</h4>
<p className="text-muted-foreground text-sm">
Real-time validation with clear error messages and feedback
</p>
</div>
</div>
</CardContent>
</Card>
);
}
export default async function ImportPage() {
return (
<div className="space-y-8">
<PageHeader
title="Import Time Entries"
description="Upload CSV files to create invoices from your time tracking data"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
<ArrowLeft className="mr-2 h-5 w-5" />
Back to Invoices
</Button>
</Link>
</PageHeader>
<HydrateClient>
{/* Main CSV Import Component */}
<CSVImportPage />
{/* File Format Help */}
<FileFormatHelp />
{/* Format Instructions */}
<FormatInstructions />
{/* Important Notes */}
<ImportantNotes />
</HydrateClient>
</div>
);
} }
+7 -19
View File
@@ -2,8 +2,9 @@ import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server"; import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { FileText, Plus, Upload } from "lucide-react"; import { DashboardPage } from "~/components/layout/dashboard-page";
import { Plus } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table"; import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
@@ -16,37 +17,24 @@ async function InvoicesTable() {
export default async function InvoicesPage() { export default async function InvoicesPage() {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Invoices" title="Invoices"
description="Manage your invoices and track payments" description="Manage your invoices and track payments"
variant="gradient"
> >
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" />
<span>Import CSV</span>
</Link>
</Button>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/new?blank=1">
<FileText className="mr-2 h-5 w-5" />
<span>Blank invoice</span>
</Link>
</Button>
<Button asChild variant="default" className="hover-lift shadow-md"> <Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new"> <Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-5 w-5" />
<span>Create Invoice</span> <span>Create Invoice</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}> <Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<InvoicesTable /> <InvoicesTable />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+19 -16
View File
@@ -13,7 +13,9 @@ import {
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { EmptyState } from "~/components/layout/page-layout";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -358,17 +360,16 @@ export default function RecurringInvoicesPage() {
const isSubmitting = create.isPending || update.isPending; const isSubmitting = create.isPending || update.isPending;
return ( return (
<div className="page-enter space-y-6 pb-24"> <DashboardPage className="pb-24">
<PageHeader <DashboardPageHeader
title="Recurring Invoices" title="Recurring Invoices"
description="Schedule automatic invoice generation" description="Schedule automatic invoice generation"
variant="gradient"
> >
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}> <Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New recurring New recurring
</Button> </Button>
</PageHeader> </DashboardPageHeader>
{isLoading ? ( {isLoading ? (
<div className="flex h-48 items-center justify-center"> <div className="flex h-48 items-center justify-center">
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
</div> </div>
) : (recurring ?? []).length === 0 ? ( ) : (recurring ?? []).length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center"> <CardContent className="p-0">
<RefreshCw className="text-muted-foreground h-10 w-10" /> <EmptyState
<p className="text-muted-foreground text-sm"> icon={<RefreshCw className="h-6 w-6" />}
No recurring invoices yet. Create one to automatically generate draft invoices on a title="Create your first recurring invoice"
schedule. description="Automatically generate draft invoices on a schedule you choose."
</p> action={
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}> <Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create first recurring invoice Create recurring invoice
</Button> </Button>
}
/>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -529,6 +532,6 @@ export default function RecurringInvoicesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+24 -18
View File
@@ -2,7 +2,14 @@
import { useState } from "react"; import { useState } from "react";
import { api, type RouterOutputs } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -18,7 +25,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -187,25 +194,24 @@ export default function TemplatesPage() {
const termsTemplates = templates.filter((t) => t.type === "terms"); const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <DashboardPage className="pb-6">
<PageHeader <DashboardPageHeader
title="Invoice Templates" title="Invoice Templates"
description="Reusable notes and payment terms for your invoices" description="Reusable notes and payment terms for your invoices"
variant="gradient"
/> />
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <PageTabsList>
<TabsTrigger value="notes"> <PageTabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ( <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length}) {notesTemplates.length})
</TabsTrigger> </PageTabsTrigger>
<TabsTrigger value="terms"> <PageTabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ( <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length}) {termsTemplates.length})
</TabsTrigger> </PageTabsTrigger>
</TabsList> </PageTabsList>
<TabsContent value="notes" className="mt-4"> <PageTabsContent value="notes">
<TemplateList <TemplateList
items={notesTemplates} items={notesTemplates}
type="notes" type="notes"
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={setDeleteId} onDelete={setDeleteId}
/> />
</TabsContent> </PageTabsContent>
<TabsContent value="terms" className="mt-4"> <PageTabsContent value="terms">
<TemplateList <TemplateList
items={termsTemplates} items={termsTemplates}
type="terms" type="terms"
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={setDeleteId} onDelete={setDeleteId}
/> />
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
{/* Create/Edit dialog */} {/* Create/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+18 -1
View File
@@ -1,7 +1,11 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AppProviders } from "~/components/providers/app-providers"; import { AppProviders } from "~/components/providers/app-providers";
import { DashboardShell } from "~/components/layout/dashboard-shell"; import { DashboardShell } from "~/components/layout/dashboard-shell";
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,9 +20,22 @@ export default async function DashboardLayout({
redirect("/auth/signin?callbackUrl=/dashboard"); redirect("/auth/signin?callbackUrl=/dashboard");
} }
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: {
role: true,
onboardingCompletedAt: true,
},
});
const isAdmin = user?.role === "admin";
const needsOnboarding = user?.onboardingCompletedAt == null;
return ( return (
<AppProviders> <AppProviders>
<DashboardShell>{children}</DashboardShell> <DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
<DashboardShell>{children}</DashboardShell>
</DashboardUserProvider>
</AppProviders> </AppProviders>
); );
} }
@@ -0,0 +1,30 @@
import { Logo } from "~/components/branding/logo";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
export function OnboardingShell({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
<div
className={cn(
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
className,
)}
>
<div className="mb-8 space-y-4 text-center">
<div className="flex justify-center">
<Logo size="lg" animated={false} />
</div>
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
export const ONBOARDING_STEPS = [
{ id: "welcome", label: "Welcome" },
{ id: "business", label: "Business" },
{ id: "client", label: "Client" },
] as const;
export type OnboardingStepId = (typeof ONBOARDING_STEPS)[number]["id"] | "done";
function stepIndex(step: OnboardingStepId) {
if (step === "done") return ONBOARDING_STEPS.length;
return ONBOARDING_STEPS.findIndex((item) => item.id === step);
}
const TRACK_GRID_COLUMNS = ONBOARDING_STEPS.map((_, index) =>
index < ONBOARDING_STEPS.length - 1 ? "auto 1fr" : "auto",
).join(" ");
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
const currentIndex = stepIndex(step);
return (
<nav aria-label="Setup progress" className="mb-8">
<ol className="sr-only">
{ONBOARDING_STEPS.map((item, index) => {
const isCurrent = currentIndex === index;
return (
<li key={item.id} aria-current={isCurrent ? "step" : undefined}>
{item.label}
{isCurrent ? " (current)" : ""}
</li>
);
})}
</ol>
{/* Row 1: circles + connectors. Row 2: labels (same columns as circles). */}
<div
className="mx-auto grid w-full max-w-md items-center gap-y-2"
style={{
gridTemplateColumns: TRACK_GRID_COLUMNS,
gridTemplateRows: "auto auto",
}}
aria-hidden
>
{ONBOARDING_STEPS.map((item, index) => {
const isComplete = currentIndex > index;
const isCurrent = currentIndex === index;
const isUpcoming = currentIndex < index;
const connectorComplete = currentIndex > index;
const circleCol = index * 2 + 1;
return (
<div key={item.id} className="contents">
{index > 0 && (
<div
className={cn(
"h-0.5 self-center rounded-full transition-colors",
connectorComplete ? "bg-primary" : "bg-border/80",
)}
style={{ gridColumn: index * 2, gridRow: 1 }}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center justify-self-center rounded-full border-2 text-sm font-medium transition-colors",
isComplete &&
"border-primary bg-primary text-primary-foreground",
isCurrent &&
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
isUpcoming &&
"border-border/80 bg-background/60 text-muted-foreground",
)}
style={{ gridColumn: circleCol, gridRow: 1 }}
>
{isComplete ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<span>{index + 1}</span>
)}
</div>
<span
className={cn(
"hidden min-w-0 justify-self-center text-center text-xs leading-tight font-medium sm:block",
isCurrent ? "text-foreground" : "text-muted-foreground",
)}
style={{ gridColumn: circleCol, gridRow: 2 }}
>
{item.label}
</span>
</div>
);
})}
</div>
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
{ONBOARDING_STEPS.length}
{step !== "done" && ONBOARDING_STEPS[currentIndex]
? ` · ${ONBOARDING_STEPS[currentIndex].label}`
: ""}
</p>
</nav>
);
}
@@ -0,0 +1,365 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Building2,
CheckCircle2,
FileText,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
import {
OnboardingStepIndicator,
type OnboardingStepId,
} from "./onboarding-step-indicator";
type Step = OnboardingStepId;
function StepIcon({
icon: Icon,
className,
}: {
icon: React.ComponentType<{ className?: string }>;
className?: string;
}) {
return (
<div
className={cn(
"bg-primary/10 text-primary mb-5 inline-flex rounded-2xl p-3",
className,
)}
>
<Icon className="h-6 w-6" />
</div>
);
}
function OnboardingPanel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
marketingSurfaceClass,
"bg-card/80 px-6 py-8 sm:px-8 sm:py-10",
className,
)}
>
{children}
</div>
);
}
export function OnboardingWizard() {
const router = useRouter();
const utils = api.useUtils();
const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery();
const [step, setStep] = useState<Step>("welcome");
const [businessName, setBusinessName] = useState("");
const [clientName, setClientName] = useState("");
const createBusiness = api.businesses.create.useMutation({
onSuccess: async () => {
toast.success("Business added");
await utils.settings.getOnboardingStatus.invalidate();
setStep("client");
},
onError: (error) => toast.error(error.message),
});
const createClient = api.clients.create.useMutation({
onSuccess: async () => {
toast.success("Client added");
await utils.settings.getOnboardingStatus.invalidate();
setStep("done");
},
onError: (error) => toast.error(error.message),
});
const completeOnboarding = api.settings.completeOnboarding.useMutation({
onSuccess: () => {
router.push("/dashboard");
router.refresh();
},
onError: (error) => toast.error(error.message),
});
useEffect(() => {
if (status?.completed) {
router.replace("/dashboard");
}
}, [status?.completed, router]);
const displayStep = useMemo((): Step => {
if (step !== "welcome" || !status || status.completed) {
return step;
}
if (status.businessCount > 0 && status.clientCount > 0) {
return "done";
}
if (status.businessCount > 0) {
return "client";
}
return step;
}, [step, status]);
function handleSkip() {
completeOnboarding.mutate();
}
function handleBusinessSubmit(e: React.FormEvent) {
e.preventDefault();
if (!businessName.trim()) {
toast.error("Business name is required");
return;
}
createBusiness.mutate({
name: businessName.trim(),
isDefault: true,
});
}
function handleClientSubmit(e: React.FormEvent) {
e.preventDefault();
if (!clientName.trim()) {
toast.error("Client name is required");
return;
}
createClient.mutate({ name: clientName.trim() });
}
function handleFinish() {
completeOnboarding.mutate();
}
function handleCreateInvoice() {
completeOnboarding.mutate(undefined, {
onSuccess: () => {
router.push("/dashboard/invoices/new");
router.refresh();
},
});
}
if (isLoading || status?.completed) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground text-sm">Loading</p>
</div>
);
}
return (
<div className="w-full">
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
{displayStep === "welcome" && (
<OnboardingPanel>
<div className="text-center">
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
Quick setup
</p>
<StepIcon icon={FileText} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Welcome to {brand.name}
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Let&apos;s set up the basics so you can send your first invoice.
This only takes a minute.
</p>
</div>
<ul className="mt-8 space-y-4">
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
<Building2 className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">Add your business</p>
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
The name and details that appear on invoices you send.
</p>
</div>
</li>
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
<Users className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">Add your first client</p>
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
Who you&apos;re billing you can add more details later.
</p>
</div>
</li>
</ul>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<Button className="h-11 flex-1" size="lg" onClick={() => setStep("business")}>
Get started
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
variant="ghost"
className="h-11"
onClick={handleSkip}
disabled={completeOnboarding.isPending}
>
Skip for now
</Button>
</div>
</OnboardingPanel>
)}
{displayStep === "business" && (
<OnboardingPanel>
<div className="text-center">
<StepIcon icon={Building2} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Your business
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
This appears on invoices as the sender name, logo, and contact
details.
</p>
</div>
<form onSubmit={handleBusinessSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="business-name">Business name</Label>
<Input
id="business-name"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
placeholder="Acme Studio LLC"
className="h-11"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
size="lg"
className="h-11 flex-1"
disabled={createBusiness.isPending}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="h-11"
onClick={handleSkip}
>
Skip for now
</Button>
</div>
</form>
</OnboardingPanel>
)}
{displayStep === "client" && (
<OnboardingPanel>
<div className="text-center">
<StepIcon icon={Users} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Your first client
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Who are you billing? You can add more details later.
</p>
</div>
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="client-name">Client name</Label>
<Input
id="client-name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Acme Corp"
className="h-11"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
size="lg"
className="h-11 flex-1"
disabled={createClient.isPending}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="h-11"
onClick={handleSkip}
>
Skip for now
</Button>
</div>
</form>
</OnboardingPanel>
)}
{displayStep === "done" && (
<OnboardingPanel className="text-center">
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
<CheckCircle2 className="h-7 w-7" />
</div>
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
You&apos;re ready to go
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Your workspace is set up. Create an invoice or explore the dashboard.
</p>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
Go to dashboard
</Button>
<Button
variant="outline"
size="lg"
className="h-11 flex-1"
onClick={handleCreateInvoice}
>
Create first invoice
</Button>
</div>
</OnboardingPanel>
)}
{step !== "welcome" && displayStep !== "done" && (
<div className="mt-6 text-center">
<Button
variant="link"
className="text-muted-foreground"
onClick={() =>
setStep(displayStep === "client" ? "business" : "welcome")
}
>
Back
</Button>
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { OnboardingShell } from "./_components/onboarding-shell";
import { OnboardingWizard } from "./_components/onboarding-wizard";
export default function OnboardingPage() {
return (
<OnboardingShell>
<OnboardingWizard />
</OnboardingShell>
);
}
+190 -308
View File
@@ -10,24 +10,34 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
import {
InvoiceStatusChart,
MonthlyMetricsChart,
RevenueChart,
} from "~/app/dashboard/_components/charts-client";
import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardCardTitle,
DashboardGrid,
DashboardPage as DashboardPageLayout,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import {
import { Skeleton } from "~/components/ui/skeleton"; Card,
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { HydrateClient, api } from "~/trpc/server"; import { cn } from "~/lib/utils";
import type { StoredInvoiceStatus } from "~/types/invoice"; import { api } from "~/trpc/server";
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
import type { DashboardStats, RecentInvoice } from "./types"; import type { DashboardStats, RecentInvoice } from "./types";
// Hero section with clean mono design
// Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) { function DashboardStats({ stats }: { stats: DashboardStats }) {
// TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => { const formatTrend = (value: number, isCount = false) => {
if (isCount) { if (isCount) {
return value > 0 ? `+${value}` : value.toString(); return value > 0 ? `+${value}` : value.toString();
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
change: formatTrend(stats.revenueChange), change: formatTrend(stats.revenueChange),
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const), trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
iconName: "DollarSign" as const, iconName: "DollarSign" as const,
description: "Total collected revenue", description: "Collected to date",
}, },
{ {
title: "Pending Amount", title: "Pending",
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`, value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: stats.pendingAmount, numericValue: stats.pendingAmount,
isCurrency: true, isCurrency: true,
change: "0%", // TODO: Calculate pending change if needed change: "0%",
trend: "neutral" as const, trend: "neutral" as const,
iconName: "Clock" as const, iconName: "Clock" as const,
description: "Invoices awaiting payment", description: "Awaiting payment",
}, },
{ {
title: "Active Clients", title: "Clients",
value: stats.totalClients.toString(), value: stats.totalClients.toString(),
numericValue: stats.totalClients, numericValue: stats.totalClients,
isCurrency: false, isCurrency: false,
change: "0", // TODO: Calculate client change if needed change: "0",
trend: "neutral" as const, trend: "neutral" as const,
iconName: "Users" as const, iconName: "Users" as const,
description: "Total registered clients", description: "Active clients",
}, },
{ {
title: "Overdue Invoices", title: "Overdue",
value: stats.overdueCount.toString(), value: stats.overdueCount.toString(),
numericValue: stats.overdueCount, numericValue: stats.overdueCount,
isCurrency: false, isCurrency: false,
change: "0", // TODO: Calculate overdue change if needed change: "0",
trend: "neutral" as const, trend: "neutral" as const,
iconName: "TrendingDown" as const, iconName: "TrendingDown" as const,
description: "Invoices past due date", description: "Past due date",
}, },
]; ];
return ( return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"> <div className={cn(dashboardGridClass, "sm:grid-cols-2 xl:grid-cols-4")}>
{statCards.map((stat, index) => ( {statCards.map((stat, index) => (
<AnimatedStatsCard <AnimatedStatsCard
key={stat.title} key={stat.title}
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
); );
} }
// Charts section function ChartsSection({ stats }: { stats: DashboardStats }) {
async function ChartsSection({ stats }: { stats: DashboardStats }) {
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
// Actually, let's fetch invoices here for the status chart to keep it working.
const invoices = await api.invoices.getAll();
return ( return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <DashboardGrid className="lg:grid-cols-2">
{/* Revenue Trend Chart */}
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<BarChart3 className="h-5 w-5" /> <DashboardCardTitle icon={BarChart3}>
Revenue Over Time Revenue over time
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Invoice Status Breakdown */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Activity className="h-5 w-5" /> <DashboardCardTitle icon={Activity}>
Invoice Status Invoice status
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<InvoiceStatusChart invoices={invoices} /> <InvoiceStatusChart data={stats.statusChartData} />
</CardContent> </CardContent>
</Card> </Card>
{/* Monthly Metrics */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Calendar className="h-5 w-5" /> <DashboardCardTitle icon={Calendar}>
Monthly Metrics Monthly metrics
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<MonthlyMetricsChart invoices={invoices} /> <MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
</CardContent> </CardContent>
</Card> </Card>
</div> </DashboardGrid>
); );
} }
// Enhanced Quick Actions
function QuickActions() { function QuickActions() {
const actions = [ const actions = [
{ {
title: "Create Invoice", title: "Create invoice",
description: "Start a new invoice for a client", description: "Start a new invoice for a client",
href: "/dashboard/invoices/new", href: "/dashboard/invoices/new",
icon: FileText, icon: FileText,
featured: true, featured: true,
}, },
{ {
title: "Add Client", title: "Add client",
description: "Register a new client", description: "Register someone you bill",
href: "/dashboard/clients/new", href: "/dashboard/clients/new",
icon: Users, icon: Users,
featured: false, featured: false,
}, },
{ {
title: "View All Invoices", title: "View invoices",
description: "Manage your invoice pipeline", description: "Browse your full pipeline",
href: "/dashboard/invoices", href: "/dashboard/invoices",
icon: BarChart3, icon: BarChart3,
featured: false, featured: false,
@@ -178,27 +181,36 @@ function QuickActions() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Plus className="h-5 w-5" /> <DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
Quick Actions
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
{actions.map((action) => { {actions.map((action) => {
const Icon = action.icon; const Icon = action.icon;
return ( return (
<Link <Link
key={action.title} key={action.title}
href={action.href} href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${ className={cn(
"flex items-start gap-3 rounded-2xl border p-4 transition-colors",
action.featured action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted" ? "border-primary/20 bg-primary/5 hover:bg-primary/10"
: "border-border bg-background hover:bg-muted/50" : "border-border/60 bg-background/50 hover:bg-muted/50",
}`} )}
> >
<Icon className="h-5 w-5 flex-shrink-0" /> <div
className={cn(
"rounded-xl p-2",
action.featured
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold">{action.title}</p> <p className="text-sm font-medium">{action.title}</p>
<p className="text-muted-foreground text-sm leading-relaxed"> <p className="text-muted-foreground text-sm leading-relaxed">
{action.description} {action.description}
</p> </p>
@@ -211,204 +223,168 @@ function QuickActions() {
); );
} }
// Current work section with enhanced design function CurrentWork({
async function CurrentWork() { currentDraft,
const invoices = await api.invoices.getAll(); }: {
const draftInvoices = invoices.filter( currentDraft: DashboardStats["currentDraft"];
(invoice) => }) {
getEffectiveInvoiceStatus( if (!currentDraft) {
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "draft",
);
const currentInvoice = draftInvoices[0];
if (!currentInvoice) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Activity className="h-5 w-5" /> <DashboardCardTitle icon={Activity}>
Current Work Current work
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col items-center py-8 text-center">
<div className="py-8 text-center"> <div className="bg-muted mb-4 rounded-2xl p-3">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <FileText className="text-muted-foreground h-6 w-6" />
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
<p className="text-muted-foreground mb-4">
Create a new invoice to get started
</p>
<Button asChild variant="outline" className="border-foreground/20">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Invoice
</Link>
</Button>
</div> </div>
<p className="font-medium">No draft in progress</p>
<CardDescription className="mt-1 max-w-xs">
Start an invoice when you&apos;re ready to bill your next piece of
work.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create invoice
</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
const totalHours = const totalHours = currentDraft.totalHours;
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Activity className="h-5 w-5" /> <DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
Current Work
</CardTitle> </CardTitle>
<Badge variant="secondary">In Progress</Badge> <Badge variant="secondary">Draft</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5">
<div className="space-y-4"> <div className="space-y-1">
<div className="space-y-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div>
<h3 className="text-lg font-semibold break-words"> <p className="font-medium">#{currentDraft.invoiceNumber}</p>
#{currentInvoice.invoiceNumber} <p className="text-muted-foreground text-sm">
</h3> {currentDraft.client?.name}
<span className="text-primary text-2xl font-bold"> </p>
${currentInvoice.totalAmount.toFixed(2)}
</span>
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{currentInvoice.client?.name}</span>
<span className="text-xs sm:text-sm">
{totalHours.toFixed(1)} hours logged
</span>
</div> </div>
<p className="font-mono text-xl font-semibold tabular-nums">
${currentDraft.totalAmount.toFixed(2)}
</p>
</div> </div>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{totalHours.toFixed(1)} hours logged
</p>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button asChild variant="outline" size="sm" className="flex-1">
asChild <Link href={`/dashboard/invoices/${currentDraft.id}`}>
variant="outline" <Eye className="mr-2 h-4 w-4" />
size="sm" View
className="hover-lift flex-1" </Link>
> </Button>
<Link href={`/dashboard/invoices/${currentInvoice.id}`}> <Button asChild size="sm" className="flex-1">
<Eye className="mr-2 h-4 w-4" /> <Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
View <Edit className="mr-2 h-4 w-4" />
</Link> Continue
</Button> </Link>
<Button asChild size="sm" className="hover-lift flex-1"> </Button>
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Continue
</Link>
</Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
// Enhanced recent activity function RecentActivity({
async function RecentActivity({
recentInvoices, recentInvoices,
}: { }: {
recentInvoices: RecentInvoice[]; recentInvoices: RecentInvoice[];
}) { }) {
// Use passed recentInvoices instead of fetching all const getStatusVariant = (status: string) => {
const getStatusStyle = (status: string) => {
switch (status) { switch (status) {
case "paid": case "paid":
return { return "default" as const;
backgroundColor: "oklch(var(--chart-2) / 0.1)",
borderColor: "oklch(var(--chart-2) / 0.3)",
color: "oklch(var(--chart-2))",
};
case "sent": case "sent":
return { return "secondary" as const;
backgroundColor: "oklch(var(--chart-1) / 0.1)",
borderColor: "oklch(var(--chart-1) / 0.3)",
color: "oklch(var(--chart-1))",
};
case "overdue": case "overdue":
return { return "destructive" as const;
backgroundColor: "oklch(var(--chart-3) / 0.1)",
borderColor: "oklch(var(--chart-3) / 0.3)",
color: "oklch(var(--chart-3))",
};
default: default:
return { return "outline" as const;
backgroundColor: "hsl(var(--muted))",
borderColor: "hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
};
} }
}; };
return ( return (
<Card> <Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Calendar className="h-5 w-5" /> <DashboardCardTitle icon={Calendar}>
Recent Activity Recent activity
</DashboardCardTitle>
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link href="/dashboard/invoices"> <Link href="/dashboard/invoices">
<span className="hidden sm:inline">View All</span> <span className="hidden sm:inline">View all</span>
<ArrowUpRight className="h-4 w-4 sm:ml-1" /> <ArrowUpRight className="h-4 w-4 sm:ml-1" />
</Link> </Link>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recentInvoices.length === 0 ? ( {recentInvoices.length === 0 ? (
<div className="py-8 text-center"> <div className="flex flex-col items-center py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <div className="bg-muted mb-4 rounded-2xl p-3">
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3> <FileText className="text-muted-foreground h-6 w-6" />
<p className="text-muted-foreground mb-4"> </div>
Create your first invoice to get started <p className="font-medium">No invoices yet</p>
</p> <CardDescription className="mt-1 max-w-xs">
<Button asChild variant="outline" className="border-foreground/20"> Your latest invoices will show up here.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new"> <Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create Your First Invoice Create invoice
</Link> </Link>
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2">
{recentInvoices.map((invoice, _index) => ( {recentInvoices.map((invoice) => (
<Link <Link
key={invoice.id} key={invoice.id}
href={`/dashboard/invoices/${invoice.id}`} href={`/dashboard/invoices/${invoice.id}`}
className="block" className="hover:bg-muted/50 border-border/60 flex items-center gap-3 rounded-2xl border p-3 transition-colors"
> >
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors"> <div className="bg-muted rounded-xl p-2">
<div className="flex items-start gap-3"> <FileText className="text-muted-foreground h-4 w-4" />
<div className="bg-muted flex-shrink-0 rounded-lg p-2"> </div>
<FileText className="text-muted-foreground h-4 w-4" /> <div className="min-w-0 flex-1">
</div> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1 space-y-2"> <p className="truncate text-sm font-medium">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> #{invoice.invoiceNumber}
<div className="min-w-0"> </p>
<p className="truncate font-medium"> <span className="shrink-0 font-mono text-sm font-medium tabular-nums">
#{invoice.invoiceNumber} ${invoice.totalAmount.toFixed(2)}
</p> </span>
<p className="text-muted-foreground truncate text-sm"> </div>
{invoice.client?.name} <div className="mt-1 flex items-center justify-between gap-2">
</p> <p className="text-muted-foreground truncate text-xs">
</div> {invoice.client?.name}
<div className="flex flex-shrink-0 items-center gap-2"> </p>
<Badge style={getStatusStyle(invoice.status)}> <Badge
{invoice.status} variant={getStatusVariant(invoice.status)}
</Badge> className="shrink-0 text-[10px]"
<span className="text-primary font-semibold"> >
${invoice.totalAmount.toFixed(2)} {invoice.status}
</span> </Badge>
</div>
</div>
<p className="text-muted-foreground text-xs">
{new Date(invoice.issueDate).toLocaleDateString()}
</p>
</div>
</div> </div>
</div> </div>
</Link> </Link>
@@ -420,121 +396,27 @@ async function RecentActivity({
); );
} }
// Loading skeletons
function StatsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="mb-2 h-8 w-20" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
</div>
);
}
function ChartsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-36" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
function CardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</CardContent>
</Card>
);
}
import { DashboardPageHeader } from "~/components/layout/page-header";
// ... imports
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await getOptionalServerSessionFromHeaders(); const session = await getOptionalServerSessionFromHeaders();
const firstName = session?.user?.name?.split(" ")[0] ?? "User"; const firstName = session?.user?.name?.split(" ")[0] ?? "User";
// Fetch stats centrally
const stats = await api.dashboard.getStats(); const stats = await api.dashboard.getStats();
void api.timeEntries.getRunning.prefetch();
return ( return (
<div className="page-enter space-y-6"> <DashboardPageLayout>
<DashboardPageHeader <DashboardPageHeader
title={`Welcome back, ${firstName}!`} title={`Welcome back, ${firstName}`}
description="Here's what's happening with your business today" description="A snapshot of your invoices, revenue, and work in progress."
/> />
<HydrateClient> <DashboardStats stats={stats} />
<Suspense fallback={<StatsSkeleton />}> <ChartsSection stats={stats} />
<DashboardStats stats={stats} /> <DashboardGrid className="lg:grid-cols-2">
</Suspense> <div className={cn(dashboardGridClass)}>
</HydrateClient> <CurrentWork currentDraft={stats.currentDraft} />
<HydrateClient>
<Suspense fallback={<ChartsSkeleton />}>
<ChartsSection stats={stats} />
</Suspense>
</HydrateClient>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-6">
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<CurrentWork />
</Suspense>
</HydrateClient>
<QuickActions /> <QuickActions />
</div> </div>
<RecentActivity recentInvoices={stats.recentInvoices} />
<HydrateClient> </DashboardGrid>
<Suspense fallback={<CardSkeleton />}> </DashboardPageLayout>
<RecentActivity recentInvoices={stats.recentInvoices} />
</Suspense>
</HydrateClient>
</div>
</div>
); );
} }
+70 -40
View File
@@ -2,7 +2,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -14,7 +15,12 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -45,10 +51,14 @@ function toNumericChartValue(value: unknown) {
} }
export default function ReportsPage() { export default function ReportsPage() {
const [businessFilter, setBusinessFilter] = useState("all");
const { data: businesses = [] } = api.businesses.getAll.useQuery();
const { data: invoices = [], isLoading: invoicesLoading } = const { data: invoices = [], isLoading: invoicesLoading } =
api.invoices.getAll.useQuery(); api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } = const { data: expenses = [], isLoading: expensesLoading } =
api.expenses.getAll.useQuery(); api.expenses.getAll.useQuery(
businessFilter === "all" ? undefined : { businessId: businessFilter },
);
const { data: stats } = api.dashboard.getStats.useQuery(); const { data: stats } = api.dashboard.getStats.useQuery();
const isLoading = invoicesLoading || expensesLoading; const isLoading = invoicesLoading || expensesLoading;
@@ -56,9 +66,14 @@ export default function ReportsPage() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear)); const [taxYear, setTaxYear] = useState(String(currentYear));
const filteredInvoices = useMemo(() => {
if (businessFilter === "all") return invoices;
return invoices.filter((inv) => inv.businessId === businessFilter);
}, [invoices, businessFilter]);
// Overview data (last 12 months) // Overview data (last 12 months)
const overviewData = useMemo(() => { const overviewData = useMemo(() => {
if (!invoices.length) return null; if (!filteredInvoices.length) return null;
const now = new Date(); const now = new Date();
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
@@ -72,7 +87,7 @@ export default function ReportsPage() {
let totalPending = 0; let totalPending = 0;
let totalHours = 0; let totalHours = 0;
for (const inv of invoices) { for (const inv of filteredInvoices) {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -96,7 +111,7 @@ export default function ReportsPage() {
})); }));
const clientMap: Record<string, { name: string; revenue: number }> = {}; const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) { for (const inv of filteredInvoices) {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -120,7 +135,7 @@ export default function ReportsPage() {
paid: 0, paid: 0,
overdue: 0, overdue: 0,
}; };
for (const inv of invoices) { for (const inv of filteredInvoices) {
const s = getEffectiveInvoiceStatus( const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -136,13 +151,13 @@ export default function ReportsPage() {
totalHours, totalHours,
statusCount, statusCount,
}; };
}, [invoices]); }, [filteredInvoices]);
// Tax summary for selected year // Tax summary for selected year
const taxData = useMemo(() => { const taxData = useMemo(() => {
const year = parseInt(taxYear); const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => { const yearInvoices = filteredInvoices.filter((inv) => {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -218,20 +233,20 @@ export default function ReportsPage() {
yearInvoices, yearInvoices,
yearExpenses, yearExpenses,
}; };
}, [invoices, expenses, taxYear]); }, [filteredInvoices, expenses, taxYear]);
const availableYears = useMemo(() => { const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]); const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of invoices) for (const inv of filteredInvoices)
years.add(new Date(inv.issueDate).getFullYear()); years.add(new Date(inv.issueDate).getFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
return Array.from(years).sort((a, b) => b - a); return Array.from(years).sort((a, b) => b - a);
}, [invoices, expenses, currentYear]); }, [filteredInvoices, expenses, currentYear]);
const avgInvoice = const avgInvoice =
invoices.length > 0 filteredInvoices.length > 0
? (overviewData?.totalRevenue ?? 0) / ? (overviewData?.totalRevenue ?? 0) /
(invoices.filter( (filteredInvoices.filter(
(i) => (i) =>
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus, i.status as StoredInvoiceStatus,
@@ -308,42 +323,57 @@ export default function ReportsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Reports" title="Reports"
description="Revenue and tax analytics" description="Revenue and tax analytics"
variant="gradient"
/> />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className={dashboardStatGridClass}>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" /> <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
))} ))}
</div> </div>
</div> </DashboardPage>
); );
} }
return ( return (
<div className="page-enter space-y-6 pb-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Reports" title="Reports"
description="Revenue and tax analytics" description="Revenue and tax analytics"
variant="gradient"
/> />
<Tabs defaultValue="overview"> <div className="mb-4 flex items-center gap-3">
<TabsList className="grid w-full grid-cols-2"> <span className="text-sm font-medium">Business</span>
<TabsTrigger value="overview"> <Select value={businessFilter} onValueChange={setBusinessFilter}>
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview <SelectTrigger className="w-52">
</TabsTrigger> <SelectValue placeholder="All businesses" />
<TabsTrigger value="tax"> </SelectTrigger>
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary <SelectContent>
</TabsTrigger> <SelectItem value="all">All businesses</SelectItem>
</TabsList> {businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PageTabs defaultValue="overview">
<PageTabsList>
<PageTabsTrigger value="overview" className="gap-1.5">
<TrendingUp className="h-4 w-4" /> Overview
</PageTabsTrigger>
<PageTabsTrigger value="tax" className="gap-1.5">
<FileText className="h-4 w-4" /> Tax Summary
</PageTabsTrigger>
</PageTabsList>
{/* ── OVERVIEW TAB ── */} {/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6"> <PageTabsContent value="overview">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className={dashboardStatGridClass}>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -569,7 +599,7 @@ export default function ReportsPage() {
<div <div
className="bg-primary h-full rounded-full" className="bg-primary h-full rounded-full"
style={{ style={{
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`, width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
}} }}
/> />
</div> </div>
@@ -580,7 +610,7 @@ export default function ReportsPage() {
</div> </div>
), ),
)} )}
{invoices.length === 0 && ( {filteredInvoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="text-muted-foreground py-6 text-center text-sm">
No invoices yet. No invoices yet.
</p> </p>
@@ -630,10 +660,10 @@ export default function ReportsPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</TabsContent> </PageTabsContent>
{/* ── TAX SUMMARY TAB ── */} {/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6"> <PageTabsContent value="tax">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span> <span className="text-sm font-medium">Tax Year</span>
@@ -840,8 +870,8 @@ export default function ReportsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
</div> </DashboardPage>
); );
} }
@@ -0,0 +1,245 @@
"use client";
import { CircleHelp, FileJson, FileSpreadsheet, FileText } from "lucide-react";
import { useState } from "react";
import {
ImportCsvTemplateButton,
ImportJsonTemplateButton,
} from "./import-sample-download";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { JSON_TEMPLATE } from "~/lib/invoice-import-templates";
const CSV_COLUMNS = [
{
field: "date",
required: false,
desc: "Work date (M/D/YY, YYYY-MM-DD, or ISO)",
},
{
field: "item",
required: false,
desc: "Short item name (combined with description if both present)",
},
{
field: "description",
required: "one of item/description",
desc: "Line item description",
},
{
field: "quantity",
required: true,
desc: "Hours or units (aliases: hours, qty)",
},
{
field: "rate",
required: true,
desc: "Unit rate (aliases: price, hourly rate)",
},
] as const;
export function ImportFormatInfoDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<CircleHelp className="mr-2 h-4 w-4" />
Format guide
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col sm:max-w-4xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Import format guide
</DialogTitle>
<DialogDescription>
CSV and JSON reference for bulk invoice imports. All imported
invoices are created as drafts for review.
</DialogDescription>
</DialogHeader>
<PageTabs defaultValue="csv" className="min-h-0 flex-1">
<PageTabsList>
<PageTabsTrigger value="csv">
<FileSpreadsheet className="mr-1.5 h-4 w-4" />
CSV
</PageTabsTrigger>
<PageTabsTrigger value="json">
<FileJson className="mr-1.5 h-4 w-4" />
JSON
</PageTabsTrigger>
</PageTabsList>
<PageTabsContent
value="csv"
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
>
<p className="text-muted-foreground text-sm">
One CSV file creates one invoice. The invoice title is the
filename without the extension (e.g.{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
acme-january.csv
</code>{" "}
title &quot;acme-january&quot;). Column headers are flexible
and auto-detected from the .csv extension.
</p>
<div className="bg-muted border-border rounded-md border p-3">
<p className="text-foreground font-mono text-sm">
date,description,quantity,rate
</p>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">
Columns (header row required)
</h4>
<div className="space-y-2">
{CSV_COLUMNS.map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="border font-mono text-xs">
{col.field}
</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
{col.required === true && " — required"}
{typeof col.required === "string" &&
`${col.required} required`}
</span>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Example rows</h4>
<div className="bg-muted border-border space-y-2 rounded-md border p-3">
<p className="text-foreground font-mono text-xs break-all">
2024-01-15,&quot;API development&quot;,8,125.00
</p>
<p className="text-foreground font-mono text-xs break-all">
1/16/24,Design review,2,125.00
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Rules</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Column names are case-insensitive. Legacy columns{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
HOURS
</code>{" "}
and{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
DATE
</code>{" "}
are still supported.
</li>
<li>
Select a default client in Settings Data before uploading
CSV files.
</li>
<li>
Each line item needs a description (or item), quantity, and
rate.
</li>
<li> Max 10 MB per file, up to 50 files at once.</li>
<li>
Preview staged invoices and fix per-row errors before you
commit the import.
</li>
</ul>
</div>
<ImportCsvTemplateButton />
</PageTabsContent>
<PageTabsContent
value="json"
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
>
<p className="text-muted-foreground text-sm">
Import one or many invoices from a single JSON file. Clients are
matched by email, then name, or created automatically when
details are provided.
</p>
<div className="space-y-2">
<h4 className="text-sm font-medium">Example</h4>
<div className="bg-muted border-border max-h-64 overflow-auto rounded-md border p-3">
<pre className="text-foreground font-mono text-xs whitespace-pre-wrap">
{JSON_TEMPLATE}
</pre>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Rules</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Root may be{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
{"{ invoices: [...] }"}
</code>
, an array, or a single invoice object.
</li>
<li>
Line items use{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
quantity
</code>{" "}
or{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
hours
</code>
.
</li>
<li>
Issue and due dates default from item dates (+30 days for
due).
</li>
<li>
New clients are created when JSON includes unknown client
details.
</li>
<li> Max 10 MB per file, up to 50 files at once.</li>
<li>
Partial success: valid invoices import; errors are reported
per row.
</li>
</ul>
</div>
<ImportJsonTemplateButton />
</PageTabsContent>
</PageTabs>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,7 @@
"use client";
import { ImportFormatInfoDialog } from "./import-format-info-dialog";
export function ImportPageHeaderActions() {
return <ImportFormatInfoDialog />;
}
@@ -0,0 +1,52 @@
"use client";
import { FileJson, FileSpreadsheet } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
downloadCsvTemplate,
downloadJsonTemplate,
} from "~/lib/invoice-import-templates";
import { cn } from "~/lib/utils";
export function ImportCsvTemplateButton({
className,
}: {
className?: string;
}) {
return (
<Button
variant="outline"
className={cn("hover-lift shadow-sm", className)}
onClick={downloadCsvTemplate}
>
<FileSpreadsheet className="mr-2 h-5 w-5" />
Download CSV template
</Button>
);
}
export function ImportJsonTemplateButton({
className,
}: {
className?: string;
}) {
return (
<Button
variant="outline"
className={cn("hover-lift shadow-sm", className)}
onClick={downloadJsonTemplate}
>
<FileJson className="mr-2 h-5 w-5" />
Download JSON template
</Button>
);
}
export function ImportTemplateButtons({ className }: { className?: string }) {
return (
<div className={className}>
<ImportCsvTemplateButton />
<ImportJsonTemplateButton />
</div>
);
}
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -1,23 +1,35 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { HydrateClient } from "~/trpc/server"; import { HydrateClient } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content"; import { SettingsContent } from "./_components/settings-content";
export default async function SettingsPage() { export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const params = await searchParams;
const validTabs = ["general", "preferences", "data", "api"] as const;
const initialTab = validTabs.includes(
params.tab as (typeof validTabs)[number],
)
? (params.tab as (typeof validTabs)[number])
: "general";
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Settings" title="Settings"
description="Manage your account preferences and data" description="Manage your account preferences and data"
variant="gradient"
/> />
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}> <Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent /> <SettingsContent initialTab={initialTab} />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
@@ -0,0 +1,30 @@
import Link from "next/link";
import { HydrateClient, api } from "~/trpc/server";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { TimeEntriesHistory } from "~/components/time-clock/time-entries-history";
import { Button } from "~/components/ui/button";
import { ArrowLeft } from "lucide-react";
export default async function TimeClockEntriesPage() {
void api.timeEntries.getAll.prefetch();
return (
<DashboardPage>
<DashboardPageHeader
title="Time entries"
description="Your completed time tracking history"
>
<Button variant="outline" asChild>
<Link href="/dashboard/time-clock">
<ArrowLeft className="mr-2 h-4 w-4" />
Time clock
</Link>
</Button>
</DashboardPageHeader>
<HydrateClient>
<TimeEntriesHistory />
</HydrateClient>
</DashboardPage>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { HydrateClient, api } from "~/trpc/server"; import { HydrateClient, api } from "~/trpc/server";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel"; import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
export default async function TimeClockPage({ export default async function TimeClockPage({
@@ -17,7 +18,7 @@ export default async function TimeClockPage({
} }
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<DashboardPageHeader <DashboardPageHeader
title="Time clock" title="Time clock"
description="Track billable hours and save them directly to an invoice" description="Track billable hours and save them directly to an invoice"
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
defaultInvoiceId={params.invoiceId} defaultInvoiceId={params.invoiceId}
/> />
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+6 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { generateInvoicePDF } from "~/lib/pdf-export"; import { generateInvoicePDF } from "~/lib/pdf-export";
import { formatLineItemDetail } from "~/lib/invoice-line-item";
import { toast } from "sonner"; import { toast } from "sonner";
function formatDate(date: Date) { function formatDate(date: Date) {
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 break-words">{item.description}</p> <p className="font-medium text-gray-900 break-words">{item.description}</p>
<p className="text-gray-500"> <p className="text-gray-500">
{item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr {formatLineItemDetail(
item.hours,
item.rate,
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
)}
</p> </p>
</div> </div>
<p className="font-semibold text-gray-900 shrink-0"> <p className="font-semibold text-gray-900 shrink-0">
+25 -52
View File
@@ -4,21 +4,33 @@ import { type Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import { import { getAppUrl } from "~/lib/app-url";
brand, import { brand } from "~/lib/branding";
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
} from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script"; import { UmamiScript } from "~/components/analytics/umami-script";
import { BrandBackground } from "~/components/layout/brand-background"; import { BrandBackground } from "~/components/layout/brand-background";
const siteTitle = `${brand.name} - Invoicing Made Simple`;
export const metadata: Metadata = { export const metadata: Metadata = {
title: `${brand.name} - Invoicing Made Simple`, metadataBase: new URL(getAppUrl()),
title: {
default: siteTitle,
template: `%s | ${brand.name}`,
},
description: brand.tagline, description: brand.tagline,
openGraph: {
title: siteTitle,
description: brand.tagline,
siteName: brand.name,
type: "website",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: siteTitle,
description: brand.tagline,
},
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
@@ -34,23 +46,6 @@ const playfair = localFont({
display: "swap", display: "swap",
}); });
const frutiger = localFont({
src: [
{
path: "../../public/fonts/frutiger/Frutiger.ttf",
weight: "400",
style: "normal",
},
{
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-frutiger",
display: "swap",
});
const geistMono = localFont({ const geistMono = localFont({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf", src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono", variable: "--font-geist-mono",
@@ -64,14 +59,8 @@ export default function RootLayout({
<html <html
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-interface-theme={defaultInterfaceTheme}
data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle}
data-color-mode="system" data-color-mode="system"
data-color-theme="slate" className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
> >
<head> <head>
<script <script
@@ -79,27 +68,11 @@ export default function RootLayout({
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
try { try {
var defaults = {
interfaceTheme: "${defaultInterfaceTheme}",
bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}",
sidebarStyle: "${defaultSidebarStyle}",
colorMode: "system",
colorTheme: "slate"
};
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}"); var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var appearance = Object.assign(defaults, stored); var colorMode = stored.colorMode || "system";
var root = document.documentElement; var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme; root.dataset.colorMode = colorMode;
root.dataset.bodyFont = appearance.bodyFontPreference; if (colorMode === "dark") root.classList.add("dark");
root.dataset.headingFont = appearance.headingFontPreference;
root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode;
root.dataset.colorTheme = appearance.colorTheme;
if (appearance.colorMode === "dark") root.classList.add("dark");
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
} catch {} } catch {}
`, `,
}} }}
+101
View File
@@ -0,0 +1,101 @@
import { ImageResponse } from "next/og";
import { brand, splitLogoText } from "~/lib/branding";
export const alt = `${brand.name} - Invoicing Made Simple`;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image() {
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ffffff",
position: "relative",
}}
>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage:
"linear-gradient(to right, rgba(128,128,128,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(128,128,128,0.07) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div
style={{
position: "absolute",
width: 520,
height: 520,
borderRadius: "50%",
backgroundColor: "rgba(163, 163, 163, 0.25)",
filter: "blur(80px)",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
padding: "0 80px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
fontSize: 72,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
<span style={{ color: "#18181b" }}>{brand.icon}</span>
<span style={{ width: 16 }} />
<span style={{ color: "#09090b" }}>{logoPrefix}</span>
<span style={{ color: "rgba(9, 9, 11, 0.7)" }}>{logoSuffix}</span>
</div>
<div
style={{
marginTop: 32,
fontSize: 40,
fontWeight: 600,
color: "#09090b",
textAlign: "center",
letterSpacing: "-0.02em",
}}
>
Invoicing Made Simple
</div>
<div
style={{
marginTop: 16,
fontSize: 22,
fontWeight: 400,
color: "#71717a",
textAlign: "center",
maxWidth: 900,
lineHeight: 1.4,
}}
>
{brand.tagline}
</div>
</div>
</div>
),
{
...size,
},
);
}
+7 -24
View File
@@ -1,8 +1,7 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { brand } from "~/lib/branding"; import { brand, splitLogoText } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface LogoProps { interface LogoProps {
@@ -11,24 +10,8 @@ interface LogoProps {
animated?: boolean; animated?: boolean;
} }
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
export function Logo({ className, size = "md", animated = true }: LogoProps) { export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance(); const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const sizeClasses = { const sizeClasses = {
sm: "text-base", sm: "text-base",
md: "text-xl", md: "text-xl",
@@ -45,7 +28,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
sizeClasses={sizeClasses} sizeClasses={sizeClasses}
logoPrefix={logoPrefix} logoPrefix={logoPrefix}
logoSuffix={logoSuffix} logoSuffix={logoSuffix}
icon={icon} icon={brand.icon}
/> />
); );
} }
@@ -67,7 +50,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight" className="text-primary font-bold tracking-tight"
> >
{icon} {brand.icon}
</motion.span> </motion.span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
@@ -75,8 +58,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
className="inline-block w-1" // Reduced from w-2 to w-1 (half space) className="inline-block w-1"
></motion.span> />
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -125,7 +108,7 @@ function LogoContent({
<span className="text-primary font-bold tracking-tight">{icon}</span> <span className="text-primary font-bold tracking-tight">{icon}</span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
<span className="inline-block w-1"></span> <span className="inline-block w-1" />
<span className="text-foreground font-bold tracking-tight"> <span className="text-foreground font-bold tracking-tight">
{logoPrefix} {logoPrefix}
</span> </span>
@@ -0,0 +1,25 @@
"use client";
import type { ReactElement } from "react";
import { ResponsiveContainer } from "recharts";
import { cn } from "~/lib/utils";
interface ResponsiveChartProps {
height?: number;
className?: string;
children: ReactElement;
}
export function ResponsiveChart({
height = 256,
className,
children,
}: ResponsiveChartProps) {
return (
<div className={cn("w-full min-w-0", className)}>
<ResponsiveContainer width="100%" height={height} minWidth={0}>
{children}
</ResponsiveContainer>
</div>
);
}
+2 -886
View File
@@ -1,886 +1,2 @@
"use client"; /** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
import {
AlertCircle,
Clock,
DollarSign,
Eye,
FileText,
Trash2,
Upload,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { FileUpload } from "~/components/forms/file-upload";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
interface CSVRow {
DATE: string;
DESCRIPTION: string;
HOURS: number;
RATE: number;
AMOUNT: number;
}
interface ParsedItem {
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface FileData {
file: File;
parsedItems: ParsedItem[];
previewData: CSVRow[];
invoiceNumber: string;
clientId: string;
issueDate: Date | null;
dueDate: Date | null;
status: "pending" | "ready" | "error";
errors: string[];
hasDateError: boolean;
}
export function CSVImportPage() {
const [files, setFiles] = useState<FileData[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(
null,
);
const [isProcessing, setIsProcessing] = useState(false);
const [progressCount, setProgressCount] = useState(0);
// Fetch clients for dropdown
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
toast.success("Invoice created successfully");
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = "";
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote inside quoted field
current += '"';
i += 2; // Skip both quotes
} else {
// Toggle quote state
inQuotes = !inQuotes;
i++;
}
} else if (char === "," && !inQuotes) {
// End of field
result.push(current.trim());
current = "";
i++;
} else {
// Regular character
current += char;
i++;
}
}
// Add the last field
result.push(current.trim());
return result;
};
const parseCSV = (csvText: string): CSVRow[] => {
const lines = csvText.split("\n");
const headers = parseCSVLine(lines[0] ?? "");
// Validate headers
const requiredHeaders = ["DATE", "DESCRIPTION", "HOURS", "RATE", "AMOUNT"];
const missingHeaders = requiredHeaders.filter((h) => !headers?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required headers: ${missingHeaders.join(", ")}`);
}
return lines
.slice(1)
.filter((line) => line.trim())
.map((line) => {
const values = parseCSVLine(line);
return {
DATE: values[0] ?? "",
DESCRIPTION: values[1] ?? "",
HOURS: parseFloat(values[2] ?? "0") || 0,
RATE: parseFloat(values[3] ?? "0") || 0,
AMOUNT: parseFloat(values[4] ?? "0") || 0,
};
})
.filter((row) => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
};
const parseDate = (dateStr: string): Date => {
// Handle m/dd/yy format
const parts = dateStr.split("/");
if (parts.length === 3) {
const month = parseInt(parts[0] ?? "1") - 1; // 0-based month
const day = parseInt(parts[1] ?? "1");
const year = parseInt(parts[2] ?? "2000") + 2000; // Assume 20xx
return new Date(year, month, day);
}
// Fallback to standard date parsing
return new Date(dateStr);
};
const handleFileSelect = async (selectedFiles: File[]) => {
for (const file of selectedFiles) {
const errors: string[] = [];
let hasDateError = false;
let issueDate: Date | null = null;
let dueDate: Date | null = null;
// Check filename format
const filenameMatch = /^(\d{4}-\d{2}-\d{2})\.csv$/.exec(file.name);
if (!filenameMatch) {
errors.push("Filename must be in YYYY-MM-DD.csv format");
hasDateError = true;
} else {
const filenameDate = filenameMatch[1] ?? "";
issueDate = new Date(filenameDate);
if (isNaN(issueDate.getTime())) {
errors.push("Invalid date in filename");
hasDateError = true;
} else {
dueDate = new Date(issueDate);
dueDate.setDate(dueDate.getDate() + 30);
}
}
try {
const text = await file.text();
const csvData = parseCSV(text);
// Parse items for invoice creation
const items = csvData.map((row) => ({
date: parseDate(row.DATE),
description: row.DESCRIPTION,
hours: row.HOURS,
rate: row.RATE,
amount: row.HOURS * row.RATE, // Calculate amount ourselves
}));
const fileData: FileData = {
file,
parsedItems: items,
previewData: csvData,
invoiceNumber: issueDate
? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`
: `INV-${Date.now()}`,
clientId: globalClientId, // Use global client if set
issueDate,
dueDate,
status: errors.length > 0 ? "error" : "pending",
errors,
hasDateError,
};
setFiles((prev) => [...prev, fileData]);
if (errors.length > 0) {
toast.error(
`${file.name} has ${errors.length} error${errors.length > 1 ? "s" : ""}`,
);
} else {
toast.success(`Parsed ${items.length} items from ${file.name}`);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
const fileData: FileData = {
file,
parsedItems: [],
previewData: [],
invoiceNumber: `INV-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
clientId: globalClientId,
issueDate: null,
dueDate: null,
status: "error",
errors: [`Error parsing CSV: ${errorMessage}`],
hasDateError: true,
};
setFiles((prev) => [...prev, fileData]);
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
}
}
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
// Apply global client to all files that don't have a client selected
const applyGlobalClient = (clientId: string) => {
setFiles((prev) =>
prev.map((file) => ({
...file,
clientId: file.clientId || clientId, // Only apply if no client is already selected
})),
);
};
const updateFileData = (index: number, updates: Partial<FileData>) => {
setFiles((prev) =>
prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
// Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
) {
const errorIndex = newErrors.indexOf(
"Filename must be in YYYY-MM-DD.csv format",
);
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
// Remove invalid date error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Invalid date in filename")
) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(
(error) =>
error.includes("Filename") || error.includes("Invalid date"),
);
}
return updatedFile;
}),
);
};
const openPreview = (index: number) => {
setSelectedFileIndex(index);
setPreviewModalOpen(true);
};
const validateFiles = () => {
const errors: string[] = [];
files.forEach((fileData) => {
// Check for existing errors
if (fileData.errors.length > 0) {
errors.push(`${fileData.file.name}: ${fileData.errors.join(", ")}`);
}
if (!fileData.clientId && !globalClientId) {
errors.push(`${fileData.file.name}: Client not selected`);
}
if (fileData.parsedItems.length === 0) {
errors.push(`${fileData.file.name}: No valid items found`);
}
if (!fileData.issueDate) {
errors.push(`${fileData.file.name}: Issue date required`);
}
if (!fileData.dueDate) {
errors.push(`${fileData.file.name}: Due date required`);
}
});
return errors;
};
const processBatch = async () => {
const errors = validateFiles();
if (errors.length > 0) {
toast.error(`Please fix the following issues:\n${errors.join("\n")}`);
return;
}
setIsProcessing(true);
setProgressCount(0);
let successCount = 0;
let errorCount = 0;
for (const fileData of files) {
try {
// Validate required fields before sending
const clientId = fileData.clientId || globalClientId;
if (!clientId) {
throw new Error(`No client selected for ${fileData.file.name}`);
}
if (!fileData.issueDate) {
throw new Error(`No issue date for ${fileData.file.name}`);
}
if (!fileData.dueDate) {
throw new Error(`No due date for ${fileData.file.name}`);
}
if (!fileData.invoiceNumber) {
throw new Error(`No invoice number for ${fileData.file.name}`);
}
if (!fileData.parsedItems || fileData.parsedItems.length === 0) {
throw new Error(`No items found for ${fileData.file.name}`);
}
const invoiceData = {
invoiceNumber: fileData.invoiceNumber,
clientId: clientId,
issueDate: fileData.issueDate,
dueDate: fileData.dueDate,
status: "draft" as const,
notes: `Imported from CSV: ${fileData.file.name}`,
items: fileData.parsedItems.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
};
console.log("Creating invoice with data:", invoiceData);
await createInvoice.mutateAsync(invoiceData);
console.log("Invoice created successfully");
successCount++;
} catch (error) {
errorCount++;
console.error(
`Failed to create invoice for ${fileData.file.name}:`,
error,
);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
toast.error(
`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`,
);
}
setProgressCount((prev) => prev + 1);
}
setIsProcessing(false);
if (successCount > 0) {
toast.success(
`Successfully created ${successCount} invoice${successCount > 1 ? "s" : ""}`,
);
}
if (errorCount > 0) {
toast.error(
`Failed to create ${errorCount} invoice${errorCount > 1 ? "s" : ""}`,
);
}
if (successCount > 0) {
setFiles([]);
}
};
const totalFiles = files.length;
const readyFiles = files.filter(
(f) =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate,
).length;
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
const totalAmount = files.reduce(
(sum, f) =>
sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0),
0,
);
return (
<div className="space-y-6">
{/* Global Client Selection */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Default Client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium">
Select Default Client (Optional)
</Label>
<select
id="global-client"
value={globalClientId}
onChange={(e) => {
const newClientId = e.target.value;
setGlobalClientId(newClientId);
if (newClientId) {
applyGlobalClient(newClientId);
}
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
This client will be automatically selected for all uploaded files.
You can still change individual files below.
</p>
</div>
</CardContent>
</Card>
{/* File Upload Area */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Upload className="text-primary h-5 w-5" />
Upload CSV Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{ "text/csv": [".csv"] }}
maxFiles={50}
maxSize={5 * 1024 * 1024} // 5MB
placeholder="Drag & drop CSV files here, or click to select"
description="Files must be named YYYY-MM-DD.csv (e.g., 2024-01-15.csv). Up to 50 files can be uploaded at once."
/>
{/* Summary Card */}
{totalFiles > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Import Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalFiles}
</div>
<div className="text-muted-foreground text-sm">Files</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalItems}
</div>
<div className="text-muted-foreground text-sm">
Total Items
</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-muted-foreground text-sm">
Total Amount
</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{readyFiles}/{totalFiles}
</div>
<div className="text-muted-foreground text-sm">Ready</div>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
{/* File List */}
{files.length > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
Uploaded Files
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div key={index} className="border-border bg-card border p-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" />
<div>
<h3 className="text-foreground truncate font-medium">
{fileData.file.name}
</h3>
<p className="text-muted-foreground text-sm">
{fileData.parsedItems.length} items {" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
hours
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openPreview(index)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeFile(index)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Invoice Number
</Label>
<Input
value={fileData.invoiceNumber}
className="h-9 text-sm"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<select
value={fileData.clientId}
onChange={(e) =>
updateFileData(index, { clientId: e.target.value })
}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">Select Client</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Issue Date
</Label>
<DatePicker
date={fileData.issueDate ?? undefined}
onDateChange={(date) =>
updateFileData(index, { issueDate: date ?? null })
}
placeholder="Select issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Due Date
</Label>
<DatePicker
date={fileData.dueDate ?? undefined}
onDateChange={(date) =>
updateFileData(index, { dueDate: date ?? null })
}
placeholder="Select due date"
className="h-9"
/>
</div>
</div>
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
Issues Found
</span>
</div>
<ul className="text-destructive space-y-1 text-sm">
{fileData.errors.map((error, errorIndex) => (
<li
key={errorIndex}
className="flex items-start gap-2"
>
<span className="text-destructive"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-4 flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Total:{" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<Badge variant="destructive" className="text-xs">
{fileData.errors.length} Error
{fileData.errors.length !== 1 ? "s" : ""}
</Badge>
)}
<Badge
variant={
fileData.errors.length > 0
? "destructive"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "default"
: "secondary"
}
className="text-xs"
>
{fileData.errors.length > 0
? "Has Errors"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "Ready"
: "Pending"}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Batch Actions */}
{files.length > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<DollarSign className="text-primary h-5 w-5" />
Create Invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="flex w-full flex-col gap-2">
<span className="text-muted-foreground text-sm">
Creating invoices... ({progressCount}/{totalFiles})
</span>
<Progress
value={Math.round((progressCount / totalFiles) * 100)}
className="h-2"
/>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{readyFiles} of {totalFiles} files ready for import
</div>
<Button
onClick={processBatch}
disabled={readyFiles === 0 || isProcessing}
variant="default"
>
{isProcessing
? "Processing..."
: `Import ${readyFiles} Invoice${readyFiles !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" />
{selectedFileIndex !== null &&
files[selectedFileIndex]?.file.name}
</DialogTitle>
<DialogDescription className="text-gray-600">
Preview of parsed CSV data
</DialogDescription>
</DialogHeader>
{selectedFileIndex !== null && files[selectedFileIndex] && (
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<div className="grid flex-shrink-0 grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center gap-2">
<FileText className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm">
{files[selectedFileIndex].parsedItems.length} items
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
total hours
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm font-medium">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="p-0">
<div className="max-h-96 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-muted-foreground p-2 text-left font-medium">
Date
</th>
<th className="text-muted-foreground p-2 text-left font-medium">
Description
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Hours
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Rate
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Amount
</th>
</tr>
</thead>
<tbody>
{files[selectedFileIndex].parsedItems.map(
(item, index) => (
<tr key={index} className="border-border border-b">
<td className="text-foreground p-2 whitespace-nowrap">
{item.date.toLocaleDateString()}
</td>
<td className="text-foreground max-w-xs truncate p-2">
{item.description}
</td>
<td className="text-foreground p-2 text-right whitespace-nowrap">
{item.hours}
</td>
<td className="text-foreground p-2 text-right whitespace-nowrap">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="text-foreground p-2 text-right font-medium whitespace-nowrap">
{item.amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
<Button
variant="outline"
onClick={() => setPreviewModalOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+66 -6
View File
@@ -24,10 +24,12 @@ import {
ChevronsRight, ChevronsRight,
Filter, Filter,
Search, Search,
SearchX,
X, X,
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import { import {
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
clearSelection: () => void, clearSelection: () => void,
) => React.ReactNode; ) => React.ReactNode;
initialSorting?: SortingState; initialSorting?: SortingState;
/** Shown when the dataset is empty (no rows in DB). */
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: React.ReactNode;
emptyAction?: React.ReactNode;
/** Shown when filters/search hide all rows but data exists. */
filteredEmptyTitle?: string;
filteredEmptyDescription?: string;
}
export interface DataTableEmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
/** Centered empty state for data tables (reuses page EmptyState). */
export function DataTableEmptyState({
icon,
title,
description,
action,
className,
}: DataTableEmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
className={cn("py-16", className)}
/>
);
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
onRowClick, onRowClick,
selectionActions, selectionActions,
initialSorting = [], initialSorting = [],
emptyTitle,
emptyDescription,
emptyIcon,
emptyAction,
filteredEmptyTitle = "No matches for your search",
filteredEmptyDescription = "Try adjusting your search or filters.",
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(initialSorting); const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
}, [globalFilter]); }, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100]; const pageSizeOptions = [5, 10, 20, 30, 50, 100];
const filteredRowCount = table.getFilteredRowModel().rows.length;
const isDatasetEmpty = data.length === 0;
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
// Handle row click // Handle row click
const handleRowClick = (row: TData, event: React.MouseEvent) => { const handleRowClick = (row: TData, event: React.MouseEvent) => {
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow className="hover:bg-transparent">
<TableCell <TableCell colSpan={columns.length} className="p-0">
colSpan={columns.length} {isDatasetEmpty && emptyTitle ? (
className="h-24 text-center" <DataTableEmptyState
> icon={emptyIcon}
<p className="text-muted-foreground">No results found</p> title={emptyTitle}
description={emptyDescription}
action={emptyAction}
/>
) : isFilteredEmpty ? (
<DataTableEmptyState
icon={<SearchX className="h-6 w-6" />}
title={filteredEmptyTitle}
description={filteredEmptyDescription}
/>
) : (
<div className="text-muted-foreground py-16 text-center text-sm">
No results found
</div>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
+14 -3
View File
@@ -155,11 +155,22 @@ export function InvoiceList() {
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> {invoice.status === "draft" ? (
<Button variant="ghost" size="sm"> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
disabled
title="Only draft invoices can be edited"
>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
</Link> )}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { FileText, Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import { isImageReceipt, receiptUrl } from "~/components/expenses/receipt-utils";
import { cn } from "~/lib/utils";
interface ReceiptPreview {
id: string;
mimeType: string;
originalFilename: string;
}
interface ExpenseReceiptIndicatorProps {
expenseId: string;
receiptCount: number;
receiptPreview: ReceiptPreview | null;
className?: string;
}
export function ExpenseReceiptIndicator({
expenseId,
receiptCount,
receiptPreview,
className,
}: ExpenseReceiptIndicatorProps) {
const [listOpen, setListOpen] = useState(false);
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId },
{ enabled: listOpen && receiptCount > 1 },
);
if (receiptCount === 0) {
return (
<span className={cn("text-muted-foreground text-xs", className)}></span>
);
}
const handleClick = () => {
if (receiptCount === 1 && receiptPreview) {
setViewerReceipt({
id: receiptPreview.id,
originalFilename: receiptPreview.originalFilename,
mimeType: receiptPreview.mimeType,
});
return;
}
setListOpen(true);
};
const previewIsImage =
receiptPreview && isImageReceipt(receiptPreview.mimeType);
return (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClick}
className={cn(
"hover:bg-muted h-auto gap-2 px-2 py-1.5 font-normal",
className,
)}
title={
receiptCount === 1
? "View receipt"
: `View ${receiptCount} receipts`
}
>
{previewIsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={receiptUrl(receiptPreview.id)}
alt=""
className="h-8 w-8 rounded object-cover ring-1 ring-black/5"
/>
) : (
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded ring-1 ring-black/5">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
)}
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Paperclip className="h-3 w-3" />
{receiptCount}
</span>
</Button>
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
<Dialog open={listOpen} onOpenChange={setListOpen}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>Receipts ({receiptCount})</DialogTitle>
</DialogHeader>
<div className="space-y-2">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : (
receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={(r) => {
setListOpen(false);
setViewerReceipt(r);
}}
compact
/>
))
)}
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { ExternalLink, Eye, FileText, Loader2, Trash2 } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import {
formatReceiptSize,
isImageReceipt,
receiptUrl,
} from "~/components/expenses/receipt-utils";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
export interface ExpenseReceiptRecord {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}
interface ExpenseReceiptItemProps {
receipt: ExpenseReceiptRecord;
expenseId: string;
onView: (receipt: ReceiptViewerTarget) => void;
compact?: boolean;
readOnly?: boolean;
}
export function ExpenseReceiptItem({
receipt,
expenseId,
onView,
compact = false,
readOnly = false,
}: ExpenseReceiptItemProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = api.useUtils();
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
onSuccess: () => {
toast.success("Receipt removed");
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
setConfirmDelete(false);
},
onError: (e) => toast.error(e.message),
});
const url = receiptUrl(receipt.id);
const isImage = isImageReceipt(receipt.mimeType);
return (
<>
<div
className={
compact
? "flex items-center gap-2 rounded-md border p-2"
: "flex items-center gap-3 rounded-md border p-2 sm:p-3"
}
>
<button
type="button"
onClick={() => onView(receipt)}
className="hover:ring-primary/40 focus-visible:ring-ring shrink-0 overflow-hidden rounded transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
aria-label={`View ${receipt.originalFilename}`}
>
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
className={
compact ? "h-10 w-10 object-cover" : "h-12 w-12 object-cover sm:h-14 sm:w-14"
}
/>
) : (
<div
className={
compact
? "bg-muted flex h-10 w-10 items-center justify-center"
: "bg-muted flex h-12 w-12 items-center justify-center sm:h-14 sm:w-14"
}
>
<FileText className="text-muted-foreground h-5 w-5 sm:h-6 sm:w-6" />
</div>
)}
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{receipt.originalFilename}
</p>
<p className="text-muted-foreground text-xs">
{formatReceiptSize(receipt.sizeBytes)}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onView(receipt)}
title="View receipt"
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" asChild>
<a href={url} target="_blank" rel="noreferrer" title="Open in new tab">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
{!readOnly && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-8 w-8 p-0"
onClick={() => setConfirmDelete(true)}
disabled={deleteReceipt.isPending}
title="Delete receipt"
>
{deleteReceipt.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete receipt?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{receipt.originalFilename}&rdquo; will be permanently
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteReceipt.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteReceipt.isPending}
onClick={(e) => {
e.preventDefault();
deleteReceipt.mutate({ id: receipt.id });
}}
>
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useRef, useState } from "react";
import { Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Label } from "~/components/ui/label";
import { FileUpload } from "~/components/forms/file-upload";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import {
fileToBase64,
RECEIPT_ACCEPT,
RECEIPT_MAX_SIZE,
RECEIPT_UPLOAD_HINT,
} from "~/components/expenses/receipt-utils";
interface ExpenseReceiptsPanelProps {
expenseId: string | null;
readOnly?: boolean;
}
export function ExpenseReceiptsPanel({
expenseId,
readOnly = false,
}: ExpenseReceiptsPanelProps) {
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const [uploadKey, setUploadKey] = useState(0);
const processedFileCountRef = useRef(0);
const utils = api.useUtils();
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId: expenseId! },
{ enabled: !!expenseId },
);
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => {
if (expenseId) {
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
}
},
onError: (e) => toast.error(e.message),
});
const handleFiles = async (files: File[]) => {
if (!expenseId || files.length === 0) return;
const newFiles = files.slice(processedFileCountRef.current);
processedFileCountRef.current = files.length;
if (newFiles.length === 0) return;
let uploaded = 0;
for (const file of newFiles) {
try {
const data = await fileToBase64(file);
await uploadReceipt.mutateAsync({
expenseId,
filename: file.name,
mimeType: file.type || "application/octet-stream",
data,
});
uploaded++;
} catch (error) {
const message =
error instanceof Error ? error.message : "Upload failed";
toast.error(`${file.name}: ${message}`);
}
}
if (uploaded > 0) {
toast.success(
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
);
processedFileCountRef.current = 0;
setUploadKey((k) => k + 1);
}
};
if (!expenseId) {
return (
<div className="space-y-2 border-t pt-4">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
</Label>
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
Save the expense first, then drag and drop receipts here.
</div>
</div>
);
}
return (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
{receipts.length > 0 && (
<span className="text-muted-foreground text-xs font-normal">
({receipts.length})
</span>
)}
</Label>
{uploadReceipt.isPending && (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading
</span>
)}
</div>
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : receipts.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
{readOnly
? "No receipts attached."
: "No receipts yet. Drop images or PDFs below."}
</div>
) : (
<div className="space-y-2">
{receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={setViewerReceipt}
readOnly={readOnly}
/>
))}
</div>
)}
{!readOnly && (
<FileUpload
key={`${expenseId}-${uploadKey}`}
onFilesSelected={(files) => void handleFiles(files)}
accept={RECEIPT_ACCEPT}
maxFiles={5}
maxSize={RECEIPT_MAX_SIZE}
disabled={uploadReceipt.isPending}
placeholder="Drop receipts here or tap to browse"
description={RECEIPT_UPLOAD_HINT}
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
/>
)}
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
export const RECEIPT_ACCEPT: Record<string, string[]> = {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
"application/pdf": [".pdf"],
};
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
export const RECEIPT_UPLOAD_HINT =
"PNG, JPG, or PDF · up to 10MB each";
export function receiptUrl(receiptId: string) {
return `/api/receipts/${receiptId}`;
}
export function isImageReceipt(mimeType: string) {
return mimeType.startsWith("image/");
}
export function isPdfReceipt(mimeType: string) {
return mimeType === "application/pdf";
}
export function formatReceiptSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export async function fileToBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
if (!base64) {
reject(new Error("Failed to read file"));
return;
}
resolve(base64);
};
reader.onerror = () =>
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}
@@ -0,0 +1,92 @@
"use client";
import { ExternalLink, FileText } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
isImageReceipt,
isPdfReceipt,
receiptUrl,
} from "~/components/expenses/receipt-utils";
export interface ReceiptViewerTarget {
id: string;
originalFilename: string;
mimeType: string;
}
interface ReceiptViewerDialogProps {
receipt: ReceiptViewerTarget | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ReceiptViewerDialog({
receipt,
open,
onOpenChange,
}: ReceiptViewerDialogProps) {
if (!receipt) return null;
const url = receiptUrl(receipt.id);
const isImage = isImageReceipt(receipt.mimeType);
const isPdf = isPdfReceipt(receipt.mimeType);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
<DialogHeader className="shrink-0">
<DialogTitle className="truncate pr-8">
{receipt.originalFilename}
</DialogTitle>
</DialogHeader>
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={receipt.originalFilename}
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
/>
) : isPdf ? (
<iframe
src={url}
title={receipt.originalFilename}
className="h-[min(70vh,720px)] w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
<FileText className="h-10 w-10" />
<p>Preview not available for this file type.</p>
<Button variant="outline" size="sm" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open file
</a>
</Button>
</div>
)}
</div>
<DialogFooter className="shrink-0 sm:justify-between">
<Button variant="outline" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open in new tab
</a>
</Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+39 -14
View File
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form"; import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -106,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch business data if editing // Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } = const { data: business, isLoading: isLoadingBusiness } =
api.businesses.getById.useQuery( api.businesses.getById.useQuery(
{ id: businessId! }, { id: businessId! },
{ enabled: mode === "edit" && !!businessId }, {
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
); );
// Fetch email configuration if editing // Fetch email configuration if editing
const { data: emailConfig, isLoading: isLoadingEmailConfig } = const { data: emailConfig, isLoading: isLoadingEmailConfig } =
api.businesses.getEmailConfig.useQuery( api.businesses.getEmailConfig.useQuery(
{ id: businessId! }, { id: businessId! },
{ enabled: mode === "edit" && !!businessId }, {
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
); );
// Update email configuration mutation // Update email configuration mutation
@@ -140,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}, },
}); });
// Load business data when editing
useEffect(() => { useEffect(() => {
if (business && mode === "edit") { // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [businessId]);
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
@@ -162,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
resendDomain: emailConfig?.resendDomain ?? "", resendDomain: emailConfig?.resendDomain ?? "",
emailFromName: emailConfig?.emailFromName ?? "", emailFromName: emailConfig?.emailFromName ?? "",
}); });
setInitialized(true);
} }
}, [business, emailConfig, mode]); }, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
const handleInputChange = (field: string, value: string | boolean) => { const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
@@ -408,7 +430,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
(mode === "edit" && isLoadingEmailConfig) (mode === "edit" && isLoadingEmailConfig)
) { ) {
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<Card> <Card>
<CardHeader> <CardHeader>
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -430,21 +452,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </DashboardPage>
); );
} }
return ( return (
<> <>
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={mode === "edit" ? "Edit Business" : "Add Business"} title={mode === "edit" ? "Edit Business" : "Add Business"}
description={ description={
mode === "edit" mode === "edit"
? "Update business information below" ? "Update business information below"
: "Enter business details below to add a new business." : "Enter business details below to add a new business."
} }
variant="gradient"
> >
<Button <Button
type="submit" type="submit"
@@ -469,9 +490,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</> </>
)} )}
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<form id="business-form" onSubmit={handleSubmit} className="space-y-6"> <form
id="business-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */} {/* Main Form Container - styled like data table */}
<div className="space-y-4"> <div className="space-y-4">
{/* Basic Information */} {/* Basic Information */}
@@ -902,7 +927,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card> </Card>
</div> </div>
</form> </form>
</div> </DashboardPage>
<FloatingActionBar <FloatingActionBar
leftContent={ leftContent={
+30 -13
View File
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { AddressForm } from "~/components/forms/address-form"; import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { import {
@@ -88,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch client data if editing // Fetch client data if editing
const { data: client, isLoading: isLoadingClient } = const { data: client, isLoading: isLoadingClient } =
api.clients.getById.useQuery( api.clients.getById.useQuery(
{ id: clientId! }, { id: clientId! },
{ enabled: mode === "edit" && !!clientId }, {
enabled: mode === "edit" && !!clientId,
refetchOnWindowFocus: false,
},
); );
const createClient = api.clients.create.useMutation({ const createClient = api.clients.create.useMutation({
@@ -116,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
}, },
}); });
// Load client data when editing
useEffect(() => { useEffect(() => {
if (client && mode === "edit") { // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [clientId]);
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (client && mode === "edit" && !initialized) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({ setFormData({
name: client.name, name: client.name,
@@ -133,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
defaultHourlyRate: client.defaultHourlyRate ?? null, defaultHourlyRate: client.defaultHourlyRate ?? null,
currency: client.currency ?? "USD", currency: client.currency ?? "USD",
}); });
setInitialized(true);
} }
}, [client, mode]); }, [client, mode, initialized]);
const handleInputChange = (field: string, value: string | number | null) => { const handleInputChange = (field: string, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
@@ -237,7 +251,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
if (mode === "edit" && isLoadingClient) { if (mode === "edit" && isLoadingClient) {
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<Card> <Card>
<CardHeader> <CardHeader>
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -259,21 +273,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </DashboardPage>
); );
} }
return ( return (
<> <>
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={mode === "edit" ? "Edit Client" : "Add Client"} title={mode === "edit" ? "Edit Client" : "Add Client"}
description={ description={
mode === "edit" mode === "edit"
? "Update client information below" ? "Update client information below"
: "Enter client details below to add a new client." : "Enter client details below to add a new client."
} }
variant="gradient"
> >
<Button <Button
type="submit" type="submit"
@@ -298,9 +311,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</> </>
)} )}
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<form id="client-form" onSubmit={handleSubmit} className="space-y-6"> <form
id="client-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */} {/* Main Form Container - styled like data table */}
<div className="space-y-4"> <div className="space-y-4">
{/* Basic Information */} {/* Basic Information */}
@@ -508,7 +525,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card> </Card>
</div> </div>
</form> </form>
</div> </DashboardPage>
<FloatingActionBar <FloatingActionBar
leftContent={ leftContent={
+5 -6
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface EmailPreviewProps { interface EmailPreviewProps {
subject: string; subject: string;
@@ -53,7 +55,7 @@ export function EmailPreview({
const calculateTotal = () => { const calculateTotal = () => {
if (!invoice?.items) return 0; if (!invoice?.items) return 0;
const subtotal = invoice.items.reduce( const subtotal = invoice.items.reduce(
(sum, item) => sum + item.hours * item.rate, (sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0, 0,
); );
const taxAmount = subtotal * (invoice.taxRate / 100); const taxAmount = subtotal * (invoice.taxRate / 100);
@@ -82,17 +84,14 @@ export function EmailPreview({
description: item.description ?? "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount ?? item.hours * item.rate, amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
customMessage: customMessage, customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business", userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail, userEmail: fromEmail,
baseUrl: baseUrl: getAppUrl(),
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
}) })
: null; : null;
+1 -1
View File
@@ -152,7 +152,7 @@ export function FileUpload({
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors", "cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
"hover:border-primary/40 hover:bg-primary/10", "hover:border-primary/40 hover:bg-primary/10",
isDragActive && "border-primary/40 bg-primary/10", isDragActive && "border-primary/40 bg-primary/10",
isDragReject && "border-destructive/40 bg-destructive/10", isDragReject && "border-destructive/40 bg-destructive/10",
@@ -33,6 +33,7 @@ import {
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
Total Total
</span> </span>
<span className="text-primary text-lg font-bold"> <span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)} ${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
+158 -174
View File
@@ -2,11 +2,20 @@
import * as React from "react"; import * as React from "react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
pageTabsGridClass,
} from "~/components/layout/page-tabs";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -17,7 +26,6 @@ import {
import { DatePicker } from "~/components/ui/date-picker"; import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view"; import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview"; import { EmailPreview } from "./email-preview";
@@ -52,6 +60,11 @@ import {
import { STATUS_OPTIONS } from "./invoice/types"; import { STATUS_OPTIONS } from "./invoice/types";
import type { InvoiceFormData, InvoiceItem } from "./invoice/types"; import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
import type { ParsedLineItem } from "~/lib/parse-line-item"; import type { ParsedLineItem } from "~/lib/parse-line-item";
import {
applyBillingTypeChange,
calculateLineItemAmount,
getLineItemBillingType,
} from "~/lib/invoice-line-item";
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel"; import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
import { CountUp } from "~/components/ui/count-up"; import { CountUp } from "~/components/ui/count-up";
@@ -62,19 +75,17 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() { function InvoiceFormSkeleton() {
return ( return (
<div className="space-y-6 pb-8"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Loading..." title="Loading..."
description="Loading invoice form" description="Loading invoice form"
variant="gradient"
/> />
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "} <div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
{/* Tabs Skeleton */} <div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-muted h-[200px] animate-pulse rounded-xl" /> <div className="bg-muted h-[200px] animate-pulse rounded-xl" />
<div className="bg-muted h-[200px] animate-pulse rounded-xl" /> <div className="bg-muted h-[200px] animate-pulse rounded-xl" />
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -95,7 +106,7 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>"); .replace(/\n/g, "<br>");
} }
function createDefaultInvoiceFormData(blank = false): InvoiceFormData { function createDefaultInvoiceFormData(): InvoiceFormData {
return { return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#", invoicePrefix: "#",
@@ -109,30 +120,27 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
taxRate: 0, taxRate: 0,
currency: "USD", currency: "USD",
defaultHourlyRate: null, defaultHourlyRate: null,
items: blank items: [
? [] {
: [ id: crypto.randomUUID(),
{ date: new Date(),
id: crypto.randomUUID(), description: "",
date: new Date(), hours: 1,
description: "", rate: 0,
hours: 1, amount: 0,
rate: 0, billingType: "hourly",
amount: 0, },
}, ],
],
}; };
} }
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const isBlank = searchParams.get("blank") === "1";
const utils = api.useUtils(); const utils = api.useUtils();
// State // State
const [formData, setFormData] = useState<InvoiceFormData>(() => const [formData, setFormData] = useState<InvoiceFormData>(() =>
createDefaultInvoiceFormData(isBlank), createDefaultInvoiceFormData(),
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -140,15 +148,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details"); const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf"); const [previewTab, setPreviewTab] = useState("pdf");
const [previewPinned, setPreviewPinned] = useState(false);
useEffect(() => {
const media = window.matchMedia("(min-width: 1024px)");
const update = () => setPreviewPinned(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
// Queries (Same as before) // Queries (Same as before)
const { data: clients, isLoading: loadingClients } = const { data: clients, isLoading: loadingClients } =
@@ -179,7 +178,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}, [invoiceId]); }, [invoiceId]);
useEffect(() => { useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) { if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] = const mappedItems: InvoiceItem[] =
existingInvoice.items?.map((item) => ({ existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -188,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount, amount: item.amount,
billingType: getLineItemBillingType(item.hours),
})) || []; })) || [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
setFormData({ setFormData({
@@ -214,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1, hours: 1,
rate: 0, rate: 0,
amount: 0, amount: 0,
billingType: "hourly",
}, },
], ],
}); });
@@ -231,9 +231,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
} }
}, [invoiceId, existingInvoice, businesses, initialized]); }, [invoiceId, existingInvoice, businesses, initialized]);
useEffect(() => {
if (
invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
!loadingInvoice &&
existingInvoice.status !== "draft"
) {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [invoiceId, existingInvoice, loadingInvoice, router]);
const totals = React.useMemo(() => { const totals = React.useMemo(() => {
const subtotal = formData.items.reduce( const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate, (sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0, 0,
); );
const taxAmount = (subtotal * formData.taxRate) / 100; const taxAmount = (subtotal * formData.taxRate) / 100;
@@ -261,9 +274,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: formData.items.map((item) => ({ items: formData.items.map((item) => ({
date: item.date, date: item.date,
description: item.description || "Service", description: item.description || "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
})), amount: calculateLineItemAmount(item.hours, item.rate),
})),
}), }),
[formData], [formData],
); );
@@ -293,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1, hours: 1,
rate: prev.defaultHourlyRate ?? 0, rate: prev.defaultHourlyRate ?? 0,
amount: prev.defaultHourlyRate ?? 0, amount: prev.defaultHourlyRate ?? 0,
billingType: "hourly",
}, },
], ],
})); }));
@@ -308,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: parsed.description, description: parsed.description,
hours: parsed.hours ?? 1, hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0, rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0), amount: calculateLineItemAmount(
parsed.hours ?? 1,
parsed.rate ?? prev.defaultHourlyRate ?? 0,
),
billingType: "hourly",
}, },
], ],
})); }));
@@ -328,14 +347,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
items: prev.items.map((item, i) => { items: prev.items.map((item, i) => {
if (i === idx) { if (i !== idx) return item;
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") { if (field === "billingType" && (value === "hourly" || value === "fixed")) {
updated.amount = updated.hours * updated.rate; const next = applyBillingTypeChange(value, item);
} return {
return updated; ...item,
...next,
billingType: value,
};
} }
return item;
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") {
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
updated.billingType = getLineItemBillingType(updated.hours);
}
return updated;
}), }),
})); }));
}; };
@@ -430,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: i.description, description: i.description,
hours: i.hours, hours: i.hours,
rate: i.rate, rate: i.rate,
amount: i.hours * i.rate, amount: calculateLineItemAmount(i.hours, i.rate),
})), })),
}; };
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined) if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
@@ -456,27 +484,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
!initialized || !initialized ||
loadingClients || loadingClients ||
loadingBusinesses || loadingBusinesses ||
(invoiceId && invoiceId !== "new" && loadingInvoice) (invoiceId && invoiceId !== "new" && loadingInvoice) ||
(invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
existingInvoice.status !== "draft")
) )
return <InvoiceFormSkeleton />; return <InvoiceFormSkeleton />;
return ( return (
<> <>
<div className="page-enter space-y-6 pb-8"> <DashboardPage>
<PageHeader <DashboardPageHeader
title={ title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
invoiceId !== "new" description="Manage your invoice"
? "Edit Invoice"
: isBlank
? "Blank Invoice"
: "Create Invoice"
}
description={
isBlank
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
variant="gradient"
> >
{invoiceId !== "new" && ( {invoiceId !== "new" && (
<Button <Button
@@ -491,43 +512,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : "Save"} {loading ? "Saving..." : "Save"}
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"> <PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}> <PageTabsList>
{/* TAB SELECTOR: w-full, p-1, visible background */} <PageTabsTrigger value="details">Details</PageTabsTrigger>
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3"> <PageTabsTrigger value="items">Items</PageTabsTrigger>
<TabsTrigger <PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
value="details" <PageTabsTrigger value="preview">Preview</PageTabsTrigger>
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" </PageTabsList>
>
Details
</TabsTrigger>
<TabsTrigger
value="items"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Items
</TabsTrigger>
<TabsTrigger
value="timesheet"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Timesheet
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
>
Preview
</TabsTrigger>
</TabsList>
{/* DETAILS TAB */} {/* DETAILS TAB */}
<TabsContent <PageTabsContent value="details">
value="details" <div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
>
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2 text-base"> <CardTitle className="flex gap-2 text-base">
@@ -770,14 +767,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </div>
</PageTabsContent>
{/* ITEMS TAB */} {/* ITEMS TAB */}
<TabsContent <PageTabsContent value="items">
value="items" <div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
className="mt-6 focus-visible:outline-none"
>
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<Card className="bg-primary/5 border-primary/20"> <Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground">Total</span> <span className="text-muted-foreground">Total</span>
@@ -799,7 +794,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<span className="text-muted-foreground">Hours</span> <span className="text-muted-foreground">Hours</span>
<span className="font-mono text-xl font-semibold"> <span className="font-mono text-xl font-semibold">
<CountUp <CountUp
value={formData.items.reduce((s, i) => s + i.hours, 0)} value={formData.items.reduce(
(s, i) => s + (i.hours > 0 ? i.hours : 0),
0,
)}
suffix="h" suffix="h"
/> />
</span> </span>
@@ -826,13 +824,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
{/* TIMESHEET TAB */} {/* TIMESHEET TAB */}
<TabsContent <PageTabsContent value="timesheet">
value="timesheet"
className="mt-6 focus-visible:outline-none"
>
<Card className="min-h-[600px] w-full"> <Card className="min-h-[600px] w-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2"> <CardTitle className="flex gap-2">
@@ -850,44 +845,51 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent <PageTabsContent value="preview">
value="preview" <Card className="overflow-hidden">
className="mt-6 focus-visible:outline-none" <CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
> <CardTitle className="flex items-center gap-2 text-base">
<Tabs <FileText className="h-4 w-4" />
value={previewTab} Preview
onValueChange={setPreviewTab} </CardTitle>
className="w-full" <div className="bg-muted flex rounded-lg p-1 text-sm">
> <button
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1"> type="button"
<TabsTrigger onClick={() => setPreviewTab("pdf")}
value="pdf" className={cn(
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" "rounded-md px-3 py-1.5 text-center font-medium transition-all",
> previewTab === "pdf"
PDF ? "bg-background text-foreground shadow"
</TabsTrigger> : "text-muted-foreground hover:text-foreground",
<TabsTrigger )}
value="email" >
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" PDF
> </button>
Email <button
</TabsTrigger> type="button"
</TabsList> onClick={() => setPreviewTab("email")}
className={cn(
<TabsContent value="pdf" className="mt-6"> "rounded-md px-3 py-1.5 text-center font-medium transition-all",
<InvoicePdfPreviewPanel input={pdfPreviewInput} /> previewTab === "email"
</TabsContent> ? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
<TabsContent value="email" className="mt-6"> )}
<Card> >
<CardHeader> Email
<CardTitle className="flex gap-2"> </button>
<Mail className="h-5 w-5" /> Email Preview </div>
</CardTitle> </CardHeader>
</CardHeader> <CardContent className="p-0">
<CardContent> {previewTab === "pdf" ? (
<InvoicePdfPreviewPanel
embedded
input={pdfPreviewInput}
enabled={activeTab === "preview" && previewTab === "pdf"}
/>
) : (
<div className="border-t p-6">
<EmailPreview <EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${ subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business" selectedBusiness?.name ?? "Your Business"
@@ -922,35 +924,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: calculateLineItemAmount(item.hours, item.rate),
})), })),
}} }}
/> />
</CardContent> </div>
</Card> )}
</TabsContent> </CardContent>
</Tabs> </Card>
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
</DashboardPage>
<aside className="hidden lg:block">
<div className="sticky top-4 space-y-4">
<InvoicePdfPreviewPanel
input={pdfPreviewInput}
enabled={previewPinned || activeTab === "preview"}
/>
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">Invoice total</span>
<span className="font-mono text-2xl font-bold">
<CountUp value={totals.total} prefix="$" />
</span>
</CardContent>
</Card>
</div>
</aside>
</div>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent> <DialogContent>
+87 -23
View File
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import {
calculateLineItemAmount,
getLineItemBillingType,
type LineItemBillingType,
} from "~/lib/invoice-line-item";
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item"; import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
import { import {
useLineItemSuggestions, useLineItemSuggestions,
type LineItemSuggestion, type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions"; } from "~/hooks/use-line-item-suggestions";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
@@ -23,6 +35,7 @@ interface InvoiceItem {
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
billingType?: LineItemBillingType;
} }
interface InvoiceLineItemsProps { interface InvoiceLineItemsProps {
@@ -149,13 +162,21 @@ function DescriptionAutocomplete({
); );
} }
const LINE_ITEM_GRID =
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>( const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => { ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid", "group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
LINE_ITEM_GRID,
)} )}
> >
<DatePicker <DatePicker
@@ -177,16 +198,36 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
disabled={readOnly} disabled={readOnly}
/> />
<NumberInput <Select
value={item.hours} value={billingType}
onChange={(value) => onUpdate(index, "hours", value)} onValueChange={(value: LineItemBillingType) =>
min={0} onUpdate(index, "billingType", value)
step={0.25} }
width="full"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
disabled={readOnly} disabled={readOnly}
/> >
<SelectTrigger className="h-8 w-full px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
{isFixed ? (
<span className="text-muted-foreground text-center text-xs"></span>
) : (
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
)}
<NumberInput <NumberInput
value={item.rate} value={item.rate}
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
/> />
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums"> <div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)} ${lineTotal.toFixed(2)}
</div> </div>
{!readOnly ? ( {!readOnly ? (
@@ -235,6 +276,10 @@ function MobileLineItem({
onDescriptionChange, onDescriptionChange,
readOnly, readOnly,
}: LineItemRowProps) { }: LineItemRowProps) {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return ( return (
<div <div
id={`invoice-item-${index}-mobile`} id={`invoice-item-${index}-mobile`}
@@ -260,20 +305,37 @@ function MobileLineItem({
date={item.date} date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())} onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm" size="sm"
className="w-[92px] shrink-0" className="w-auto shrink-0"
inputClassName="h-8 px-2 text-xs" inputClassName="h-8 px-2 text-xs"
disabled={readOnly} disabled={readOnly}
/> />
<NumberInput <Select
value={item.hours} value={billingType}
onChange={(value) => onUpdate(index, "hours", value)} onValueChange={(value: LineItemBillingType) =>
min={0} onUpdate(index, "billingType", value)
step={0.25} }
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
disabled={readOnly} disabled={readOnly}
/> >
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
{!isFixed ? (
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
) : null}
<NumberInput <NumberInput
value={item.rate} value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)} onChange={(value) => onUpdate(index, "rate", value)}
@@ -285,7 +347,7 @@ function MobileLineItem({
disabled={readOnly} disabled={readOnly}
/> />
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums"> <span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)} ${lineTotal.toFixed(2)}
</span> </span>
{!readOnly ? ( {!readOnly ? (
<Button <Button
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
onUpdateItem(index, "description", s.description); onUpdateItem(index, "description", s.description);
onUpdateItem(index, "hours", s.hours); onUpdateItem(index, "hours", s.hours);
onUpdateItem(index, "rate", s.rate); onUpdateItem(index, "rate", s.rate);
onUpdateItem(index, "billingType", "hourly");
setSuggestions([]); setSuggestions([]);
setQueriedIndex(null); setQueriedIndex(null);
} }
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
) : null} ) : null}
<AnimatePresence> <AnimatePresence>
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border"> <div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid"> <div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
<span>Date</span> <span>Date</span>
<span>Description</span> <span>Description</span>
<span className="text-center">Type</span>
<span className="text-center">Hours</span> <span className="text-center">Hours</span>
<span className="text-center">Rate</span> <span className="text-center">Rate</span>
<span className="text-right">Amount</span> <span className="text-right">Amount</span>
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
enabled?: boolean; enabled?: boolean;
className?: string; className?: string;
heightClassName?: string; heightClassName?: string;
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
embedded?: boolean;
}; };
export function InvoicePdfPreviewPanel({ export function InvoicePdfPreviewPanel({
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
enabled = true, enabled = true,
className, className,
heightClassName = "h-[min(80vh,760px)]", heightClassName = "h-[min(80vh,760px)]",
embedded = false,
}: InvoicePdfPreviewPanelProps) { }: InvoicePdfPreviewPanelProps) {
const previewReady = canPreview(input); const previewReady = canPreview(input);
@@ -54,6 +57,48 @@ export function InvoicePdfPreviewPanel({
staleTime: 5_000, staleTime: 5_000,
}); });
const previewBody = (
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
heightClassName,
)}
>
{!previewReady ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client and add descriptions for all line items to generate the
PDF preview.
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-destructive text-sm">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
Try again
</Button>
</div>
) : isFetching && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Generating preview
</div>
) : pdfPreview ? (
<iframe
title="Invoice PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
);
if (embedded) {
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
}
return ( return (
<Card className={cn("overflow-hidden", className)}> <Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -63,43 +108,7 @@ export function InvoicePdfPreviewPanel({
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null} {isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">{previewBody}</CardContent>
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
heightClassName,
)}
>
{!previewReady ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client and add descriptions for all line items to generate the
PDF preview.
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-destructive text-sm">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
Try again
</Button>
</div>
) : isFetching && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Generating preview
</div>
) : pdfPreview ? (
<iframe
title="Invoice PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
</Card> </Card>
); );
} }
+3
View File
@@ -3,6 +3,8 @@ import { type RouterOutputs } from "~/trpc/react";
export type ClientType = RouterOutputs["clients"]["getAll"][number]; export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number]; export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
import type { LineItemBillingType } from "~/lib/invoice-line-item";
export interface InvoiceItem { export interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
@@ -10,6 +12,7 @@ export interface InvoiceItem {
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
billingType: LineItemBillingType;
} }
export interface InvoiceFormData { export interface InvoiceFormData {
+680
View File
@@ -0,0 +1,680 @@
"use client";
import {
AlertCircle,
Building2,
DollarSign,
Eye,
FileJson,
FileSpreadsheet,
FileText,
Trash2,
Upload,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { FileUpload } from "~/components/forms/file-upload";
import {
dashboardGapClass,
dashboardGridClass,
dashboardStatGridClass,
} from "~/components/layout/dashboard-page";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
detectImportFormat,
parseInvoiceCSV,
parseInvoiceJSON,
type ImportFormat,
type ImportInvoice,
} from "~/lib/invoice-import";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
interface StagedInvoice extends ImportInvoice {
id: string;
clientId: string;
format: ImportFormat;
}
const NONE = "__none__";
function newId() {
return crypto.randomUUID();
}
export function InvoiceImportPage() {
const [invoices, setInvoices] = useState<StagedInvoice[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [globalBusinessId, setGlobalBusinessId] = useState("");
const [previewId, setPreviewId] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
const utils = api.useUtils();
const bulkImport = api.invoices.bulkImport.useMutation({
onSuccess: (result) => {
void utils.invoices.getAll.invalidate();
if (result.clientsCreated > 0) {
void utils.clients.getAll.invalidate();
}
const parts = [
`${result.invoicesCreated} invoice${result.invoicesCreated !== 1 ? "s" : ""} created`,
];
if (result.clientsCreated > 0) {
parts.push(
`${result.clientsCreated} client${result.clientsCreated !== 1 ? "s" : ""} created`,
);
}
toast.success(parts.join(", "));
if (result.errors.length > 0) {
toast.warning(
`${result.errors.length} invoice${result.errors.length !== 1 ? "s" : ""} skipped:\n${result.errors.slice(0, 3).join("\n")}${result.errors.length > 3 ? "\n..." : ""}`,
);
}
setInvoices([]);
},
onError: (error) => {
toast.error(error.message || "Import failed");
},
});
const applyGlobalClient = (clientId: string) => {
setInvoices((prev) =>
prev.map((inv) => ({
...inv,
clientId: inv.clientId || clientId,
})),
);
};
const handleFileSelect = async (selectedFiles: File[]) => {
for (const file of selectedFiles) {
const format = detectImportFormat(file.name);
const text = await file.text();
if (format === "json") {
const parsed = parseInvoiceJSON(text);
const staged: StagedInvoice[] = parsed.map((inv) => ({
...inv,
id: newId(),
clientId: globalClientId,
format: "json" as const,
sourceFile: file.name,
}));
setInvoices((prev) => [...prev, ...staged]);
const errorCount = staged.filter((s) => s.errors.length > 0).length;
if (errorCount > 0) {
toast.error(
`${file.name}: ${errorCount} invoice${errorCount !== 1 ? "s" : ""} with validation issues`,
);
} else {
toast.success(
`Parsed ${staged.length} invoice${staged.length !== 1 ? "s" : ""} from ${file.name}`,
);
}
} else {
const parsed = parseInvoiceCSV(text, file.name);
const staged: StagedInvoice = {
...parsed,
id: newId(),
clientId: globalClientId,
format: "csv",
};
setInvoices((prev) => [...prev, staged]);
if (parsed.errors.length > 0) {
toast.error(
`${file.name}: ${parsed.errors.length} issue${parsed.errors.length !== 1 ? "s" : ""}`,
);
} else {
toast.success(
`Parsed ${parsed.items.length} items from ${file.name}`,
);
}
}
}
};
const removeInvoice = (id: string) => {
setInvoices((prev) => prev.filter((inv) => inv.id !== id));
};
const updateInvoice = (id: string, updates: Partial<StagedInvoice>) => {
setInvoices((prev) =>
prev.map((inv) => {
if (inv.id !== id) return inv;
const updated = { ...inv, ...updates };
if (updates.issueDate !== undefined && !updates.dueDate) {
const due = new Date(updated.issueDate ?? new Date());
due.setDate(due.getDate() + 30);
updated.dueDate = due;
}
return updated;
}),
);
};
const isReady = (inv: StagedInvoice) =>
inv.errors.length === 0 &&
inv.items.length > 0 &&
!!(inv.clientId || globalClientId || inv.client?.name) &&
!!inv.issueDate &&
!!inv.dueDate;
const readyCount = invoices.filter(isReady).length;
const validateBeforeImport = (): string[] => {
const errors: string[] = [];
if (!globalBusinessId && (!businesses || businesses.length === 0)) {
errors.push("Create a business in Settings before importing");
}
invoices.forEach((inv) => {
if (inv.errors.length > 0) {
errors.push(`${inv.name}: ${inv.errors.join("; ")}`);
}
if (inv.items.length === 0) {
errors.push(`${inv.name}: no line items`);
}
if (!inv.clientId && !globalClientId && !inv.client?.name) {
errors.push(`${inv.name}: client required`);
}
if (!inv.issueDate) errors.push(`${inv.name}: issue date required`);
if (!inv.dueDate) errors.push(`${inv.name}: due date required`);
});
return errors;
};
const processImport = async () => {
const errors = validateBeforeImport();
if (errors.length > 0) {
toast.error(`Fix these issues first:\n${errors.slice(0, 5).join("\n")}`);
return;
}
const readyInvoices = invoices.filter(isReady);
if (readyInvoices.length === 0) return;
setIsProcessing(true);
try {
await bulkImport.mutateAsync({
defaultClientId: globalClientId || undefined,
defaultBusinessId: globalBusinessId || undefined,
invoices: readyInvoices.map((inv) => ({
name: inv.name,
issueDate: inv.issueDate,
dueDate: inv.dueDate,
clientId: inv.clientId || globalClientId || undefined,
client: inv.client,
items: inv.items.map((item) => ({
date: item.date,
description: item.description,
quantity: item.quantity,
rate: item.rate,
})),
sourceFile: inv.sourceFile,
})),
});
} finally {
setIsProcessing(false);
}
};
const previewInvoice = previewId
? invoices.find((i) => i.id === previewId)
: null;
const totalItems = invoices.reduce((sum, inv) => sum + inv.items.length, 0);
const totalAmount = invoices.reduce(
(sum, inv) =>
sum + inv.items.reduce((s, item) => s + item.quantity * item.rate, 0),
0,
);
return (
<div className={cn("flex flex-col", dashboardGapClass)}>
{/* Upload — primary action */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="text-primary h-5 w-5" />
Upload files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{
"text/csv": [".csv"],
"application/json": [".json"],
}}
maxFiles={50}
maxSize={10 * 1024 * 1024}
placeholder="Drag & drop CSV or JSON files here, or click to select"
description="CSV: one file = one invoice. JSON: multiple invoices per file."
/>
{invoices.length > 0 && (
<div className={cn("bg-primary/10 p-4", dashboardStatGridClass)}>
<SummaryStat label="Invoices" value={invoices.length} />
<SummaryStat label="Line items" value={totalItems} />
<SummaryStat
label="Total amount"
value={totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
/>
<SummaryStat
label="Ready"
value={`${readyCount}/${invoices.length}`}
/>
</div>
)}
</CardContent>
</Card>
{/* Defaults */}
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="text-primary h-5 w-5" />
Default business
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-business" className="text-sm font-medium">
Business for imported invoices
</Label>
<Select
value={globalBusinessId || NONE}
onValueChange={(value) =>
setGlobalBusinessId(value === NONE ? "" : value)
}
disabled={loadingBusinesses}
>
<SelectTrigger id="global-business" className="h-11">
<SelectValue placeholder="Use default business" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Use default business</SelectItem>
{businesses?.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
{b.isDefault ? " (default)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Required your default business is used if none is selected.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Default client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium">
Client for CSV imports (optional)
</Label>
<Select
value={globalClientId || NONE}
onValueChange={(value) => {
const id = value === NONE ? "" : value;
setGlobalClientId(id);
if (id) applyGlobalClient(id);
}}
disabled={loadingClients}
>
<SelectTrigger id="global-client" className="h-11">
<SelectValue placeholder="No default client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
No default (JSON client or per-invoice)
</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
CSV files need a client. JSON can include client details per
invoice.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Staged invoices */}
{invoices.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoices.map((inv) => (
<div
key={inv.id}
className="border-border bg-muted/20 space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{inv.format === "json" ? (
<FileJson className="text-primary h-5 w-5 shrink-0" />
) : (
<FileSpreadsheet className="text-primary h-5 w-5 shrink-0" />
)}
<div className="min-w-0">
<h3 className="text-foreground truncate font-medium">
{inv.name}
</h3>
<p className="text-muted-foreground text-sm">
{inv.items.length} items
{inv.sourceFile ? `${inv.sourceFile}` : ""}
{inv.client?.name ? `${inv.client.name}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPreviewId(inv.id)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeInvoice(inv.id)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Invoice title
</Label>
<Input
value={inv.name}
className="h-9 text-sm"
onChange={(e) =>
updateInvoice(inv.id, { name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<Select
value={inv.clientId || NONE}
onValueChange={(value) =>
updateInvoice(inv.id, {
clientId: value === NONE ? "" : value,
})
}
disabled={loadingClients}
>
<SelectTrigger className="h-9">
<SelectValue
placeholder={
inv.client?.name
? `Use JSON: ${inv.client.name}`
: "Select client"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
{inv.client?.name
? `Use JSON: ${inv.client.name}`
: "Select client"}
</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Issue date
</Label>
<DatePicker
date={inv.issueDate}
onDateChange={(date) =>
updateInvoice(inv.id, { issueDate: date })
}
placeholder="Issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Due date
</Label>
<DatePicker
date={inv.dueDate}
onDateChange={(date) =>
updateInvoice(inv.id, { dueDate: date })
}
placeholder="Due date"
className="h-9"
/>
</div>
</div>
{inv.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
Issues
</span>
</div>
<ul className="text-destructive space-y-1 text-sm">
{inv.errors.map((err, i) => (
<li key={i}> {err}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Total:{" "}
{inv.items
.reduce((s, item) => s + item.quantity * item.rate, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
<Badge variant={isReady(inv) ? "default" : "secondary"}>
{isReady(inv) ? "Ready" : "Pending"}
</Badge>
</div>
</div>
))}
</CardContent>
</Card>
)}
{invoices.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="text-primary h-5 w-5" />
Import invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="flex w-full flex-col gap-2">
<span className="text-muted-foreground text-sm">
Importing {readyCount} invoice
{readyCount !== 1 ? "s" : ""}...
</span>
<Progress value={50} className="h-2" />
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-muted-foreground text-sm">
{readyCount} of {invoices.length} ready all imported as
drafts
</span>
<Button
onClick={processImport}
disabled={readyCount === 0 || isProcessing}
className="sm:shrink-0"
>
{isProcessing
? "Importing..."
: `Import ${readyCount} Invoice${readyCount !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={!!previewId} onOpenChange={() => setPreviewId(null)}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
{previewInvoice?.name}
</DialogTitle>
<DialogDescription>Line item preview</DialogDescription>
</DialogHeader>
{previewInvoice && (
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
Date
</th>
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
Description
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Qty
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Rate
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Amount
</th>
</tr>
</thead>
<tbody>
{previewInvoice.items.map((item, idx) => (
<tr key={idx} className="border-border border-b">
<td className="p-2 text-sm whitespace-nowrap">
{item.date?.toLocaleDateString() ?? "—"}
</td>
<td className="max-w-xs truncate p-2 text-sm">
{item.description}
</td>
<td className="p-2 text-right text-sm">{item.quantity}</td>
<td className="p-2 text-right text-sm">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="p-2 text-right text-sm font-medium">
{(item.quantity * item.rate).toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewId(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function SummaryStat({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="text-center">
<div className="text-primary text-2xl font-bold">{value}</div>
<div className="text-muted-foreground text-sm">{label}</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { cn } from "~/lib/utils";
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
export const dashboardGapClass = "gap-5 md:gap-6";
/** Standard grid gap for dashboard cards and sections. */
export const dashboardGridClass =
"grid gap-5 md:gap-6";
/** Summary stat cards (2-up mobile, 4-up desktop). */
export const dashboardStatGridClass =
"grid grid-cols-2 gap-4 sm:grid-cols-4";
export function DashboardPage({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"page-enter mx-auto flex w-full max-w-7xl flex-col",
dashboardGapClass,
className,
)}
>
{children}
</div>
);
}
export function DashboardGrid({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(dashboardGridClass, className)}>{children}</div>
);
}
export function DashboardCardTitle({
children,
icon: Icon,
}: {
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<span className="flex items-center gap-2">
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
{children}
</span>
);
}
+26 -29
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "~/components/layout/sidebar"; import { Sidebar } from "~/components/layout/sidebar";
import { import {
SidebarProvider, SidebarProvider,
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance(); const pathname = usePathname();
const [isMobileOpen, setIsMobileOpen] = React.useState(false); const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const isOnboarding = pathname === "/dashboard/onboarding";
return ( return (
<div className="bg-dashboard relative flex min-h-screen"> <div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */} {!isOnboarding && (
<div className="hidden md:block"> <div className="hidden md:block">
<Sidebar /> <Sidebar />
</div> </div>
)}
{/* Mobile Sidebar (Sheet) */} <div
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden"> className={cn(
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
isOnboarding && "hidden",
)}
>
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}> <Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
{/* Mobile Link / Logo */} <div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<div className="ml-4 flex items-center gap-2"> <Logo size="sm" className="shrink-0" />
<Logo size="sm" /> <ActiveTimerWidget compact />
</div> </div>
<SheetContent side="left" className="w-72 p-0"> <SheetContent side="left" className="w-72 p-0">
<div className="sr-only"> <div className="sr-only">
@@ -53,30 +60,20 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Sheet> </Sheet>
</div> </div>
{/* Main Content */}
<main <main
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out", "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
"md:ml-0", !isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
)} )}
> >
<div className="dashboard-content-shell p-4 pt-16 md:pt-4"> {isOnboarding ? (
<div className="mb-4 md:hidden"> <OnboardingGuard>{children}</OnboardingGuard>
{/* Mobile Breadcrumbs could go here or be part of the page */} ) : (
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div> </div>
<div className="mb-4"> )}
<ActiveTimerWidget />
</div>
{children}
</div>
</main> </main>
</div> </div>
); );
@@ -0,0 +1,29 @@
"use client";
import { createContext, useContext } from "react";
interface DashboardUserContextValue {
isAdmin: boolean;
needsOnboarding: boolean;
}
const DashboardUserContext = createContext<DashboardUserContextValue>({
isAdmin: false,
needsOnboarding: false,
});
export function DashboardUserProvider({
isAdmin,
needsOnboarding,
children,
}: DashboardUserContextValue & { children: React.ReactNode }) {
return (
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
{children}
</DashboardUserContext.Provider>
);
}
export function useDashboardUser() {
return useContext(DashboardUserContext);
}

Some files were not shown because too many files have changed in this diff Show More