Compare commits
21
Commits
6ec26a4a0d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3b1362a9 | ||
|
|
40624f6e6f | ||
|
|
d589b94580 | ||
|
|
da219b287e | ||
|
|
316edb4b3e | ||
|
|
8866335c50 | ||
|
|
fb731885de | ||
|
|
1605322bc9 | ||
|
|
ef826dffa9 | ||
|
|
d0c916d659 | ||
|
|
a66d05f24f | ||
|
|
122a3c64f5 | ||
|
|
31151e7f39 | ||
|
|
c267b3e1c8 | ||
|
|
5e6e51337a | ||
|
|
b9a9b813d2 | ||
|
|
85df7c4627 | ||
|
|
6b73c32c25 | ||
|
|
5978c8d903 | ||
|
|
3fb61ff4dd | ||
|
|
c53f2e6c4d |
+42
-21
@@ -4,14 +4,15 @@
|
||||
#
|
||||
# Quick start (local dev):
|
||||
# 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 dev
|
||||
# Garage S3 API: http://localhost:3900
|
||||
#
|
||||
# Quick start (Docker app + Postgres):
|
||||
# cp .env.example .env
|
||||
# # edit AUTH_SECRET + public URLs below
|
||||
# docker compose up -d --build
|
||||
# ./scripts/docker-deploy.sh
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
# Build-time vs runtime (Docker)
|
||||
@@ -31,7 +32,8 @@
|
||||
# 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).
|
||||
#
|
||||
# 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).
|
||||
|
||||
# =============================================================================
|
||||
@@ -62,8 +64,9 @@ NODE_ENV=development
|
||||
# Set true when connecting to local Postgres without SSL (default for compose).
|
||||
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
|
||||
GARAGE_API_PORT=3900
|
||||
|
||||
# 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.
|
||||
@@ -75,6 +78,10 @@ POSTGRES_PORT=5432
|
||||
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 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.
|
||||
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
|
||||
POSTGRES_USER=postgres
|
||||
@@ -85,27 +92,13 @@ POSTGRES_DB=postgres
|
||||
# White-label defaults (optional)
|
||||
# =============================================================================
|
||||
# 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_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
||||
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
||||
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)
|
||||
# =============================================================================
|
||||
@@ -126,12 +119,40 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
||||
# Access control (optional)
|
||||
# =============================================================================
|
||||
|
||||
# Block new email/password registrations. Use literal true or false.
|
||||
# DISABLE_SIGNUPS=true
|
||||
# Block new email/password registrations (default: true / signups off).
|
||||
# Set DISABLE_SIGNUPS=false to allow new email/password signups.
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
|
||||
# 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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -24,6 +24,7 @@ next-env.d.ts
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.data/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
@@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
ARG BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# 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)
|
||||
ENV DOCKER_BUILD=1 \
|
||||
DISABLE_REACT_COMPILER=1 \
|
||||
NODE_ENV=production \
|
||||
SKIP_ENV_VALIDATION=1 \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
|
||||
@@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432):
|
||||
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):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
@@ -123,29 +132,50 @@ docker compose build --no-cache app
|
||||
### 2. First start (or after code changes)
|
||||
|
||||
```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.
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
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? |
|
||||
|---------|-----------|-----------------|
|
||||
| `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 --build` | Yes | Yes — on app container start |
|
||||
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
|
||||
| `./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) |
|
||||
|
||||
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`.
|
||||
|
||||
### 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
|
||||
|
||||
Registration is **enabled** by default. To block new email/password accounts:
|
||||
@@ -176,7 +206,9 @@ beenvoice-web/
|
||||
├── src/lib/ # auth, PDF, email, branding helpers
|
||||
├── drizzle/ # SQL migrations
|
||||
├── 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)
|
||||
└── docs/ # Architecture and UI guides
|
||||
```
|
||||
@@ -204,12 +236,13 @@ bun run lint:fix
|
||||
bun run format:write
|
||||
bun run typecheck
|
||||
|
||||
# Docker helpers (Postgres only — uses Colima on macOS)
|
||||
bun run docker:up # colima start + docker-compose.dev.yml up -d
|
||||
# Docker helpers
|
||||
bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
|
||||
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
|
||||
|
||||
@@ -235,6 +268,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [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 |
|
||||
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "beenvoice",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@better-auth/expo": "^1.6.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
@@ -102,6 +103,60 @@
|
||||
"packages": {
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -17,5 +17,58 @@ services:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
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:
|
||||
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
@@ -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:
|
||||
app:
|
||||
build:
|
||||
@@ -5,7 +15,9 @@ services:
|
||||
args:
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_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:
|
||||
NODE_ENV: production
|
||||
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_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:-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:-GK3515373e4c851ebaad366558}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||
S3_REGION: ${S3_REGION:-garage}
|
||||
ports:
|
||||
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
garage:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
@@ -46,5 +66,58 @@ services:
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# S3-compatible receipt storage (~50–100 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:
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
|
||||
+137
@@ -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 (~50–100 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.
|
||||
@@ -8,6 +8,7 @@
|
||||
|----------|-------------|
|
||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
|
||||
| [../README.md](../README.md) | Install, scripts, deployment |
|
||||
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
|
||||
|
||||
## 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";
|
||||
@@ -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"
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "beenvoice_platform_setting"
|
||||
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "beenvoice_platform_setting"
|
||||
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -120,6 +120,48 @@
|
||||
"when": 1781500000000,
|
||||
"tag": "0016_fix_send_reminder_at_column",
|
||||
"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
@@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [".next"],
|
||||
ignores: [".next", "scripts/**"],
|
||||
},
|
||||
...nextCoreWebVitals,
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"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:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||
"docker:deploy": "./scripts/docker-deploy.sh",
|
||||
"deploy": "drizzle-kit push && next build",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
@@ -26,6 +27,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@better-auth/expo": "^1.6.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
|
||||
Executable
+32
@@ -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 "$@"
|
||||
@@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||
import crypto from "crypto";
|
||||
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -15,22 +13,35 @@ export async function POST(request: NextRequest) {
|
||||
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@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -42,60 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
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
|
||||
}
|
||||
await sendPasswordResetForUser(user.id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -3,6 +3,9 @@ import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
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 { db } from "~/server/db";
|
||||
import { accounts, users } from "~/server/db/schema";
|
||||
@@ -69,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
});
|
||||
if (rateLimit) return rateLimit;
|
||||
|
||||
if (env.DISABLE_SIGNUPS === true) {
|
||||
return NextResponse.json(
|
||||
{ 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 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({
|
||||
where: eq(users.email, normalizedEmail),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "User with this email already exists" },
|
||||
{ error: "Registration failed. Please check the form or sign in." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -118,12 +136,15 @@ export async function POST(request: NextRequest) {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const role = await resolveNewUserRole(tx);
|
||||
|
||||
const [user] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
name: `${firstName} ${lastName}`,
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
|
||||
@@ -161,6 +182,12 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
|
||||
const databaseSetupError = getDatabaseSetupErrorMessage(error);
|
||||
if (databaseSetupError) {
|
||||
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
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 { accounts, users } from "~/server/db/schema";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
});
|
||||
if (ipRateLimit) return ipRateLimit;
|
||||
|
||||
const { token, password } = (await request.json()) as {
|
||||
token: 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
|
||||
const user = await db.query.users.findFirst({
|
||||
where: and(
|
||||
eq(users.resetToken, token),
|
||||
eq(users.resetToken, tokenHash),
|
||||
gt(users.resetTokenExpiry, new Date()),
|
||||
),
|
||||
});
|
||||
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
await revokeUserSessions(user.id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
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 { users } from "~/server/db/schema";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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 };
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
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
|
||||
const user = await db.query.users.findFirst({
|
||||
where: and(
|
||||
eq(users.resetToken, token),
|
||||
eq(users.resetToken, tokenHash),
|
||||
gt(users.resetTokenExpiry, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ export async function GET(
|
||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z, type ZodType } from "zod";
|
||||
|
||||
import { createCaller } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/trpc";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -856,7 +857,7 @@ const tools = {
|
||||
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
||||
handler: async (input, caller) => {
|
||||
const result = await caller.invoices.generatePublicToken(input);
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
const base = getAppUrl();
|
||||
return {
|
||||
...result,
|
||||
webUrl: `${base}/i/${result.token}`,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,213 +1,6 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { env } from "~/env";
|
||||
import { RegisterForm } from "./register-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { env } from "~/env";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { safeCallbackPath } from "~/lib/safe-callback-url";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SignInFormProps {
|
||||
@@ -25,8 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
const signupDisabled = searchParams.get("signup") === "disabled";
|
||||
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -40,8 +40,15 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
const rateLimited =
|
||||
error.status === 429 ||
|
||||
message.includes("too many") ||
|
||||
message.includes("rate limit");
|
||||
toast.error(
|
||||
error.message && error.message !== "Required"
|
||||
rateLimited
|
||||
? "Too many sign-in attempts. Please wait a moment and try again."
|
||||
: error.message && error.message !== "Required"
|
||||
? error.message
|
||||
: "Invalid email or password",
|
||||
);
|
||||
@@ -74,7 +81,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
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">
|
||||
New account registration is currently disabled.
|
||||
</p>
|
||||
@@ -155,7 +162,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{allowRegistration && !signupDisabled && (
|
||||
{allowRegistration && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
|
||||
@@ -7,13 +7,37 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Square, Clock } from "lucide-react";
|
||||
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 { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
|
||||
undefined,
|
||||
{
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -56,9 +80,6 @@ export function ActiveTimerWidget() {
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
@@ -69,24 +90,119 @@ export function ActiveTimerWidget() {
|
||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<span className="relative flex h-3 w-3 flex-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-3 w-3 rounded-full" />
|
||||
</span>
|
||||
const description = formatRunningTimerLabel(running.description);
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
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">
|
||||
{running.description || (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
)}
|
||||
{description}
|
||||
{running.client && (
|
||||
<span className="text-muted-foreground font-normal"> · {running.client.name}</span>
|
||||
<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 (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="relative mt-1 flex h-2.5 w-2.5 flex-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.5 w-2.5 rounded-full" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-snug font-medium">
|
||||
{description}
|
||||
{running.client && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
· {running.client.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-snug">
|
||||
{invoiceLabel ? (
|
||||
<>
|
||||
Billing to{" "}
|
||||
@@ -106,27 +222,21 @@ export function ActiveTimerWidget() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 w-full">
|
||||
<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
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => clockOut.mutate({})}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
{renderStopButton("w-full")}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
Clock,
|
||||
Users,
|
||||
} 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";
|
||||
|
||||
@@ -51,41 +58,36 @@ export function AnimatedStatsCard({
|
||||
const isPositive = trend === "up";
|
||||
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 isCurrency;
|
||||
void numericValue;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className="text-muted-foreground h-5 w-5" />
|
||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="h-4 w-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div
|
||||
className="flex items-center space-x-1 text-xs"
|
||||
style={{
|
||||
color: isNeutral
|
||||
? "hsl(var(--muted-foreground))"
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs font-medium",
|
||||
isNeutral
|
||||
? "text-muted-foreground"
|
||||
: isPositive
|
||||
? "oklch(var(--chart-2))"
|
||||
: "oklch(var(--chart-3))",
|
||||
}}
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<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>
|
||||
<span className="font-mono tabular-nums">{change}</span>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"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 { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
totalAmount: number;
|
||||
export interface StatusChartDatum {
|
||||
status: string;
|
||||
dueDate: Date | string;
|
||||
name: string;
|
||||
count: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface InvoiceStatusChartProps {
|
||||
invoices: Invoice[];
|
||||
data: StatusChartDatum[];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
@@ -47,52 +46,26 @@ function StatusTooltip({
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<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" : ""}
|
||||
</p>
|
||||
<p className="text-sm">{formatChartCurrency(data.value)}</p>
|
||||
<p className="font-mono text-sm tabular-nums">
|
||||
{formatChartCurrency(data.value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InvoiceStatusChart({ invoices }: 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
|
||||
export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) {
|
||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||
useAnimationPreferences();
|
||||
const pieAnimationDuration = Math.round(
|
||||
600 / (animationSpeedMultiplier || 1),
|
||||
);
|
||||
|
||||
if (chartData.length === 0) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveChart height={192} className="h-48">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={40}
|
||||
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
animationDuration={pieAnimationDuration}
|
||||
animationEasing="ease-out"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
</Pie>
|
||||
<Tooltip content={<StatusTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ResponsiveChart>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2">
|
||||
{chartData.map((item) => (
|
||||
{data.map((item) => (
|
||||
<div key={item.status} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{item.count}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="font-mono text-sm font-medium tabular-nums">
|
||||
{item.count}
|
||||
</p>
|
||||
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{formatChartCurrency(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
totalAmount: number;
|
||||
issueDate: Date | string;
|
||||
status: string;
|
||||
dueDate: Date | string;
|
||||
export interface MonthlyMetricsChartDatum {
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
totalInvoices: number;
|
||||
paidInvoices: number;
|
||||
pendingInvoices: number;
|
||||
overdueInvoices: number;
|
||||
draftInvoices: number;
|
||||
}
|
||||
|
||||
interface MonthlyMetricsChartProps {
|
||||
invoices: Invoice[];
|
||||
data: MonthlyMetricsChartDatum[];
|
||||
}
|
||||
|
||||
function MonthlyMetricsTooltip({
|
||||
@@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: {
|
||||
paidInvoices: number;
|
||||
pendingInvoices: number;
|
||||
overdueInvoices: number;
|
||||
draftInvoices: number;
|
||||
totalInvoices: number;
|
||||
};
|
||||
payload: MonthlyMetricsChartDatum;
|
||||
}>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
const chartDatum = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{label}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
|
||||
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
|
||||
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
|
||||
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
|
||||
<p className="text-foreground border-t pt-1 font-medium">
|
||||
Total: {data.totalInvoices}
|
||||
<p className="text-primary font-medium font-mono tabular-nums">
|
||||
Paid: {chartDatum.paidInvoices}
|
||||
</p>
|
||||
<p className="text-primary/80 font-mono tabular-nums">
|
||||
Pending: {chartDatum.pendingInvoices}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MonthlyMetricsChart({ invoices }: 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
|
||||
export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) {
|
||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||
useAnimationPreferences();
|
||||
const barAnimationDuration = Math.round(
|
||||
500 / (animationSpeedMultiplier || 1),
|
||||
);
|
||||
|
||||
if (chartData.length === 0) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData}>
|
||||
<ResponsiveChart height={192} className="h-48">
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
axisLine={false}
|
||||
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: "var(--muted-foreground)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<MonthlyMetricsTooltip />} />
|
||||
<Bar
|
||||
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ResponsiveChart>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
interface RevenueChartProps {
|
||||
@@ -41,7 +41,10 @@ const CustomTooltip = ({
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<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)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -84,8 +87,7 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveChart height={256} className="h-48 md:h-64">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
||||
<YAxis
|
||||
axisLine={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}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ResponsiveChart>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
"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 { 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,6 +41,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,74 +59,374 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function AdministrationContent() {
|
||||
const {
|
||||
data: accounts = [],
|
||||
refetch,
|
||||
error,
|
||||
} = api.settings.listAccounts.useQuery();
|
||||
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Account role updated");
|
||||
void refetch();
|
||||
},
|
||||
onError: (mutationError: { message: string }) => {
|
||||
toast.error(`Failed to update role: ${mutationError.message}`);
|
||||
},
|
||||
});
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"user.profile_updated": "Profile updated",
|
||||
"user.role_updated": "Role updated",
|
||||
"user.password_reset_sent": "Password reset sent",
|
||||
"platform.pdf_settings_updated": "PDF settings updated",
|
||||
};
|
||||
|
||||
function formatAction(action: string) {
|
||||
return ACTION_LABELS[action] ?? action;
|
||||
}
|
||||
|
||||
function AdminOverview() {
|
||||
const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<Card>
|
||||
<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" />
|
||||
Administration
|
||||
Platform overview
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Administrative access is required for this page.
|
||||
Aggregate counts only — no customer data, credentials, or bulk PII.
|
||||
</CardDescription>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Accounts
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
Users
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage account access and roles without opening customer data.
|
||||
Search accounts, edit profiles, and manage access.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<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={account.id}
|
||||
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
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">
|
||||
<p className="text-sm font-medium">{account.name}</p>
|
||||
<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">
|
||||
{account.email}
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Created {new Date(account.createdAt).toLocaleDateString()}
|
||||
Joined{" "}
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={account.role}
|
||||
onValueChange={(role) =>
|
||||
updateAccountRoleMutation.mutate({
|
||||
userId: account.id,
|
||||
role: role as "user" | "admin",
|
||||
<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",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-36">
|
||||
<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>
|
||||
@@ -94,8 +435,187 @@ export function AdministrationContent() {
|
||||
</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>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" />
|
||||
Audit log
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recent administrative actions across the platform.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading audit log…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="h-6 w-6" />}
|
||||
title="No audit events yet"
|
||||
description="Administrative actions will appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-border divide-y border">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="space-y-1 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{formatAction(entry.action)}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{entry.targetType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{entry.actor?.name ?? "Unknown admin"} ·{" "}
|
||||
{new Date(entry.createdAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{entry.targetId ? ` · target ${entry.targetId.slice(0, 8)}…` : ""}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
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 { AdministrationContent } from "./_components/administration-content";
|
||||
|
||||
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 (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Administration"
|
||||
description="Manage account access and platform administration"
|
||||
variant="gradient"
|
||||
description="Platform statistics, user management, and audit logging"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
|
||||
<AdministrationContent />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 Link from "next/link";
|
||||
import {
|
||||
@@ -43,11 +49,10 @@ export default async function BusinessDetailPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
||||
description="View business details and information"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/entities?tab=businesses">
|
||||
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
|
||||
<span>Edit Business</span>
|
||||
</Link>
|
||||
</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 */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-card border-border border">
|
||||
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
|
||||
</div>
|
||||
|
||||
{/* Settings & Actions Card */}
|
||||
<div className="space-y-6">
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
data={searchableBusinesses}
|
||||
searchKey="searchValue"
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 {
|
||||
Edit,
|
||||
@@ -57,11 +63,10 @@ export default async function ClientDetailPage({
|
||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={client.name}
|
||||
description="View client details and information"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/entities?tab=clients">
|
||||
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
|
||||
<span>Edit Client</span>
|
||||
</Link>
|
||||
</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 */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-card border-border border">
|
||||
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className="space-y-6">
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -179,6 +179,17 @@ export function ClientsDataTable({
|
||||
data={clients}
|
||||
searchKey="name"
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,15 +3,32 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { ClientsTable } from "../../clients/_components/clients-table";
|
||||
import { BusinessesTable } from "../../businesses/_components/businesses-table";
|
||||
import { ClientsDataTable } from "../../clients/_components/clients-data-table";
|
||||
import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
|
||||
import type { RouterOutputs } from "~/trpc/react";
|
||||
|
||||
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 searchParams = useSearchParams();
|
||||
const tab: EntityTab =
|
||||
@@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
||||
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<>
|
||||
<DashboardPageHeader
|
||||
title="Entities"
|
||||
description="Clients you bill and businesses you send from"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href={addHref}>
|
||||
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
||||
<span>{addLabel}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="clients">Clients</TabsTrigger>
|
||||
<TabsTrigger value="businesses">Businesses</TabsTrigger>
|
||||
</TabsList>
|
||||
<PageTabs value={tab} onValueChange={handleTabChange}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="clients">Clients</PageTabsTrigger>
|
||||
<PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<TabsContent value="clients" className="mt-6">
|
||||
<ClientsTable />
|
||||
</TabsContent>
|
||||
<PageTabsContent value="clients">
|
||||
{tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="businesses" className="mt-6">
|
||||
<BusinessesTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<PageTabsContent value="businesses">
|
||||
{tab === "businesses" ? (
|
||||
<BusinessesDataTable businesses={businesses} />
|
||||
) : null}
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { api } from "~/trpc/server";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { EntitiesView } from "./_components/entities-view";
|
||||
|
||||
export default async function EntitiesPage({
|
||||
@@ -11,16 +10,18 @@ export default async function EntitiesPage({
|
||||
const params = await searchParams;
|
||||
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
||||
|
||||
void api.clients.getAll.prefetch();
|
||||
void api.businesses.getAll.prefetch();
|
||||
const [clients, businesses] = await Promise.all([
|
||||
api.clients.getAll(),
|
||||
api.businesses.getAll(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
|
||||
<EntitiesView initialTab={initialTab} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
<DashboardPage>
|
||||
<EntitiesView
|
||||
initialTab={initialTab}
|
||||
clients={clients}
|
||||
businesses={businesses}
|
||||
/>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "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 { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -26,10 +31,25 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel";
|
||||
import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Receipt,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
interface ExpenseFormData {
|
||||
date: Date;
|
||||
@@ -42,6 +62,7 @@ interface ExpenseFormData {
|
||||
taxDeductible: boolean;
|
||||
notes: string;
|
||||
clientId: string;
|
||||
businessId: string;
|
||||
}
|
||||
|
||||
const defaultForm: ExpenseFormData = {
|
||||
@@ -55,24 +76,72 @@ const defaultForm: ExpenseFormData = {
|
||||
taxDeductible: false,
|
||||
notes: "",
|
||||
clientId: "",
|
||||
businessId: "",
|
||||
};
|
||||
|
||||
type ExpenseDialogMode = "create" | "view" | "edit";
|
||||
type ExpenseFilter = "all" | "billable" | "deductible" | "receipts";
|
||||
|
||||
function expenseToForm(
|
||||
expense: {
|
||||
date: Date | string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string | null;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
taxDeductible: boolean | null;
|
||||
notes: string | null;
|
||||
clientId: string | null;
|
||||
businessId: string | null;
|
||||
},
|
||||
defaultBusinessId: string,
|
||||
): ExpenseFormData {
|
||||
return {
|
||||
date: new Date(expense.date),
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
businessId: expense.businessId ?? defaultBusinessId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<ExpenseDialogMode>("create");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [businessFilter, setBusinessFilter] = useState("all");
|
||||
const [expenseFilter, setExpenseFilter] = useState<ExpenseFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery(
|
||||
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
|
||||
const defaultBusinessId = useMemo(
|
||||
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
|
||||
[businesses],
|
||||
);
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Expense added");
|
||||
onSuccess: (expense) => {
|
||||
if (!expense) return;
|
||||
toast.success("Expense saved — you can now attach receipts");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setForm(defaultForm);
|
||||
setEditId(expense.id);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
@@ -82,6 +151,7 @@ export default function ExpensesPage() {
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
@@ -95,25 +165,29 @@ export default function ExpensesPage() {
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditId(null);
|
||||
setForm(defaultForm);
|
||||
setDialogMode("create");
|
||||
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleView = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setDialogMode("view");
|
||||
setForm(expenseToForm(expense, defaultBusinessId));
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setForm({
|
||||
date: new Date(expense.date),
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
});
|
||||
setDialogMode("edit");
|
||||
setForm(expenseToForm(expense, defaultBusinessId));
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
@@ -128,6 +202,7 @@ export default function ExpensesPage() {
|
||||
const payload = {
|
||||
...form,
|
||||
clientId: form.clientId || undefined,
|
||||
businessId: form.businessId || undefined,
|
||||
category: form.category || undefined,
|
||||
notes: form.notes || undefined,
|
||||
taxDeductible: form.taxDeductible,
|
||||
@@ -136,20 +211,69 @@ export default function ExpensesPage() {
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const filteredExpenses = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return expenses.filter((expense) => {
|
||||
if (expenseFilter === "billable" && !expense.billable) return false;
|
||||
if (expenseFilter === "deductible" && !expense.taxDeductible)
|
||||
return false;
|
||||
if (expenseFilter === "receipts" && expense.receiptCount === 0)
|
||||
return false;
|
||||
if (!needle) return true;
|
||||
return [
|
||||
expense.description,
|
||||
expense.category,
|
||||
expense.notes,
|
||||
expense.business?.name,
|
||||
expense.client?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => value?.toLowerCase().includes(needle));
|
||||
});
|
||||
}, [expenseFilter, expenses, search]);
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const visibleTotal = filteredExpenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses
|
||||
.filter((e) => e.billable)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
const deductibleTotal = expenses
|
||||
.filter((e) => e.taxDeductible)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
const withReceipts = expenses.filter((e) => e.receiptCount > 0).length;
|
||||
const hasActiveFilters =
|
||||
search.trim().length > 0 ||
|
||||
expenseFilter !== "all" ||
|
||||
businessFilter !== "all";
|
||||
|
||||
const isViewMode = dialogMode === "view";
|
||||
const isEditMode = dialogMode === "edit";
|
||||
const isCreateMode = dialogMode === "create";
|
||||
|
||||
const dialogTitle = isCreateMode
|
||||
? "Add expense"
|
||||
: isViewMode
|
||||
? "View expense"
|
||||
: "Edit expense";
|
||||
|
||||
const businessName =
|
||||
businesses.find((b) => b.id === form.businessId)?.name ??
|
||||
(form.businessId ? "Unknown business" : "Default business");
|
||||
const clientName = form.clientId
|
||||
? (clients.find((c) => c.id === form.clientId)?.name ?? "Unknown client")
|
||||
: "No client";
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(form.date);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Expenses"
|
||||
description="Track billable and non-billable expenses"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
onClick={handleOpen}
|
||||
@@ -158,10 +282,9 @@ export default function ExpensesPage() {
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className={dashboardStatGridClass}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
@@ -195,19 +318,72 @@ export default function ExpensesPage() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Count
|
||||
With receipts
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||
<p className="mt-1 text-2xl font-bold">{withReceipts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Expenses list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" /> All Expenses
|
||||
<Receipt className="h-5 w-5" /> Expenses
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{filteredExpenses.length === expenses.length
|
||||
? `${expenses.length} recorded`
|
||||
: `${filteredExpenses.length} of ${expenses.length} shown`}
|
||||
{filteredExpenses.length !== expenses.length
|
||||
? ` · ${formatCurrency(visibleTotal)} visible`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative sm:w-64">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search expenses"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||
<SelectTrigger className="sm:w-52">
|
||||
<SelectValue placeholder="All businesses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All businesses</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["all", "All"] as const,
|
||||
["billable", "Billable"] as const,
|
||||
["deductible", "Deductible"] as const,
|
||||
["receipts", "With receipts"] as const,
|
||||
].map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant={expenseFilter === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setExpenseFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -215,18 +391,59 @@ export default function ExpensesPage() {
|
||||
Loading…
|
||||
</div>
|
||||
) : expenses.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No expenses yet. Add your first expense.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Receipt className="h-6 w-6" />}
|
||||
title="Create your first expense"
|
||||
description="Track billable costs, reimbursements, and tax-deductible spending."
|
||||
action={
|
||||
<Button onClick={handleOpen}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add expense
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : filteredExpenses.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Search className="h-6 w-6" />}
|
||||
title="No matching expenses"
|
||||
description="Adjust the search or filters to bring expenses back into view."
|
||||
action={
|
||||
hasActiveFilters ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setExpenseFilter("all");
|
||||
setBusinessFilter("all");
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground hidden border-b px-4 py-2 text-xs font-medium tracking-wide uppercase sm:grid sm:grid-cols-[minmax(0,1fr)_104px_116px_44px] sm:gap-3">
|
||||
<span>Expense</span>
|
||||
<span className="text-center">Receipts</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{expenses.map((expense) => (
|
||||
{filteredExpenses.map((expense) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-start justify-between gap-3 p-4"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleView(expense)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleView(expense);
|
||||
}
|
||||
}}
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[minmax(0,1fr)_104px_116px_44px] sm:items-center sm:gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -261,6 +478,7 @@ export default function ExpensesPage() {
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && (
|
||||
@@ -269,41 +487,162 @@ export default function ExpensesPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<p className="font-semibold">
|
||||
|
||||
<div
|
||||
className="flex items-center sm:justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-muted-foreground mr-2 text-xs sm:hidden">
|
||||
Receipts
|
||||
</span>
|
||||
<ExpenseReceiptIndicator
|
||||
expenseId={expense.id}
|
||||
receiptCount={expense.receiptCount}
|
||||
receiptPreview={expense.receiptPreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="font-semibold sm:text-right">
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleEdit(expense)}
|
||||
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-8 w-8 p-0"
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label={`Actions for ${expense.description}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleView(expense)}>
|
||||
<Receipt className="mr-2 h-4 w-4" />
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(expense)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteId(expense.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
{isCreateMode && (
|
||||
<DialogDescription>
|
||||
Fill in the details below. You can attach receipts after saving.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{isViewMode ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm">{form.description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Amount
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(form.amount, form.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Date
|
||||
</p>
|
||||
<p className="text-sm">{formattedDate}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Category
|
||||
</p>
|
||||
<p className="text-sm">{form.category || "None"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Business
|
||||
</p>
|
||||
<p className="text-sm">{businessName}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Client
|
||||
</p>
|
||||
<p className="text-sm">{clientName}</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Flags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.billable ? (
|
||||
<Badge variant="secondary">Billable</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Not billable</Badge>
|
||||
)}
|
||||
{form.reimbursable ? (
|
||||
<Badge variant="outline">Reimbursable</Badge>
|
||||
) : null}
|
||||
{form.taxDeductible ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-green-600"
|
||||
>
|
||||
Tax deductible
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{form.notes ? (
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Notes
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{form.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input
|
||||
@@ -328,7 +667,9 @@ export default function ExpensesPage() {
|
||||
<Label>Currency</Label>
|
||||
<Select
|
||||
value={form.currency}
|
||||
onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, currency: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -359,7 +700,10 @@ export default function ExpensesPage() {
|
||||
<Select
|
||||
value={form.category || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
category: v === "none" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -376,12 +720,40 @@ export default function ExpensesPage() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Business</Label>
|
||||
<Select
|
||||
value={form.businessId || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
businessId: v === "none" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Default business</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
{b.isDefault ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client (optional)</Label>
|
||||
<Select
|
||||
value={form.clientId || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
clientId: v === "none" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -436,26 +808,55 @@ export default function ExpensesPage() {
|
||||
placeholder="Additional details…"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
<DialogFooter className="gap-2 sm:gap-3">
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setDialogMode("edit")}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleSubmit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
>
|
||||
{create.isPending || update.isPending
|
||||
? "Saving…"
|
||||
: editId
|
||||
: isEditMode
|
||||
? "Update"
|
||||
: "Add Expense"}
|
||||
: "Save & add receipts"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -476,6 +877,6 @@ export default function ExpensesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
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() {
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||
{/* Invoice Header Skeleton */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import {
|
||||
formatLineItemDetail,
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
<div className="sm:hidden">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||
{formatCurrency(item.rate)}/hr
|
||||
{formatDate(item.date)} ·{" "}
|
||||
{formatLineItemDetail(item.hours, item.rate, formatCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -69,9 +73,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "hours",
|
||||
header: "Hours",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{row.getValue("hours")}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hours = row.getValue<number>("hours");
|
||||
return (
|
||||
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
@@ -80,9 +87,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "rate",
|
||||
header: "Rate",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="text-right">
|
||||
{isFixedLineItem(item.hours)
|
||||
? "—"
|
||||
: `${formatCurrency(item.rate)}/hr`}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
interface InvoiceTimerCardProps {
|
||||
invoiceId: string;
|
||||
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
|
||||
|
||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TimeClockPanel
|
||||
compact
|
||||
defaultClientId={clientId}
|
||||
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 },
|
||||
{ enabled: false },
|
||||
);
|
||||
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
|
||||
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -59,11 +59,13 @@ export function PDFDownloadButton({
|
||||
};
|
||||
|
||||
await generateInvoicePDF(pdfData, {
|
||||
pdfTemplate: platformTheme?.pdfTemplate,
|
||||
pdfAccentColor: platformTheme?.pdfAccentColor,
|
||||
pdfFooterText: platformTheme?.pdfFooterText,
|
||||
pdfShowLogo: platformTheme?.pdfShowLogo,
|
||||
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
|
||||
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||
pdfFontFamily: pdfSettings?.pdfFontFamily,
|
||||
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
|
||||
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||
});
|
||||
toast.success("PDF downloaded successfully");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
export default function InvoiceFormPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
interface EditInvoicePageProps {
|
||||
params: Promise<{ id: 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} />;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,17 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
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 { toast } from "sonner";
|
||||
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 { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -83,6 +89,7 @@ function daysSince(date: Date) {
|
||||
|
||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
@@ -100,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||
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 = () => {
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||
@@ -224,24 +238,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-24">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
) : null}
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||
{/* Invoice Header */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
@@ -531,7 +546,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
{storedStatus === "draft" && (
|
||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||
)}
|
||||
@@ -544,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||
@@ -833,7 +850,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
@@ -17,7 +16,20 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { EmailComposer } from "~/components/forms/email-composer";
|
||||
import { EmailPreview } from "~/components/forms/email-preview";
|
||||
@@ -36,21 +48,20 @@ import {
|
||||
|
||||
function SendEmailPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice email"
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
|
||||
<div className="bg-muted h-96 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
<div className="bg-muted h-64 animate-pulse" />
|
||||
</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 canSend =
|
||||
@@ -292,18 +303,18 @@ export default function SendEmailPage() {
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<DashboardPage>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>Invoice not found.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||
"en-US",
|
||||
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
|
||||
day: "numeric",
|
||||
},
|
||||
).format(new Date())}`}
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Invoice
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{/* Warning for missing email */}
|
||||
{(!toEmail || toEmail.trim() === "") && (
|
||||
@@ -336,22 +346,21 @@ export default function SendEmailPage() {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="compose" className="flex items-center gap-2">
|
||||
<PageTabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="compose" className="gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Compose
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<div className="mt-6">
|
||||
<TabsContent value="compose" className="space-y-6">
|
||||
<PageTabsContent value="compose">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -387,9 +396,9 @@ export default function SendEmailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-6">
|
||||
<PageTabsContent value="preview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
{/* Invoice Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CheckCircle,
|
||||
Send,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
{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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
searchPlaceholder="Search invoices..."
|
||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||
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) =>
|
||||
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||
}
|
||||
|
||||
@@ -1,235 +1,5 @@
|
||||
import {
|
||||
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";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// File Upload Instructions Component
|
||||
function FormatInstructions() {
|
||||
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,"Web development work",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 "draft" 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>
|
||||
);
|
||||
export default function ImportPage() {
|
||||
redirect("/dashboard/settings?tab=data");
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { FileText, Plus, Upload } from "lucide-react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { Plus } from "lucide-react";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
|
||||
@@ -16,37 +17,24 @@ async function InvoicesTable() {
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Invoices"
|
||||
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">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Create Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<InvoicesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
@@ -358,17 +360,16 @@ export default function RecurringInvoicesPage() {
|
||||
const isSubmitting = create.isPending || update.isPending;
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-24">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Recurring Invoices"
|
||||
description="Schedule automatic invoice generation"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New recurring
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
|
||||
</div>
|
||||
) : (recurring ?? []).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<RefreshCw className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No recurring invoices yet. Create one to automatically generate draft invoices on a
|
||||
schedule.
|
||||
</p>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={<RefreshCw className="h-6 w-6" />}
|
||||
title="Create your first recurring invoice"
|
||||
description="Automatically generate draft invoices on a schedule you choose."
|
||||
action={
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create first recurring invoice
|
||||
Create recurring invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -529,6 +532,6 @@ export default function RecurringInvoicesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { useState } from "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 { Card, CardContent } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -18,7 +25,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||
|
||||
@@ -187,25 +194,24 @@ export default function TemplatesPage() {
|
||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-6">
|
||||
<DashboardPageHeader
|
||||
title="Invoice Templates"
|
||||
description="Reusable notes and payment terms for your invoices"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="notes">
|
||||
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="notes">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
||||
{notesTemplates.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terms">
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="terms">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
||||
{termsTemplates.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="notes" className="mt-4">
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="notes">
|
||||
<TemplateList
|
||||
items={notesTemplates}
|
||||
type="notes"
|
||||
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="terms" className="mt-4">
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="terms">
|
||||
<TemplateList
|
||||
items={termsTemplates}
|
||||
type="terms"
|
||||
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
|
||||
{/* Create/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppProviders } from "~/components/providers/app-providers";
|
||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,9 +20,22 @@ export default async function DashboardLayout({
|
||||
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 (
|
||||
<AppProviders>
|
||||
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DashboardUserProvider>
|
||||
</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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { OnboardingShell } from "./_components/onboarding-shell";
|
||||
import { OnboardingWizard } from "./_components/onboarding-wizard";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<OnboardingShell>
|
||||
<OnboardingWizard />
|
||||
</OnboardingShell>
|
||||
);
|
||||
}
|
||||
+166
-284
@@ -10,24 +10,34 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
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 { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/server";
|
||||
import type { DashboardStats, RecentInvoice } from "./types";
|
||||
|
||||
// Hero section with clean mono design
|
||||
|
||||
// Enhanced stats cards with better visuals
|
||||
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
// TODO: Import RouterOutput type
|
||||
const formatTrend = (value: number, isCount = false) => {
|
||||
if (isCount) {
|
||||
return value > 0 ? `+${value}` : value.toString();
|
||||
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
change: formatTrend(stats.revenueChange),
|
||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" 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 })}`,
|
||||
numericValue: stats.pendingAmount,
|
||||
isCurrency: true,
|
||||
change: "0%", // TODO: Calculate pending change if needed
|
||||
change: "0%",
|
||||
trend: "neutral" as const,
|
||||
iconName: "Clock" as const,
|
||||
description: "Invoices awaiting payment",
|
||||
description: "Awaiting payment",
|
||||
},
|
||||
{
|
||||
title: "Active Clients",
|
||||
title: "Clients",
|
||||
value: stats.totalClients.toString(),
|
||||
numericValue: stats.totalClients,
|
||||
isCurrency: false,
|
||||
change: "0", // TODO: Calculate client change if needed
|
||||
change: "0",
|
||||
trend: "neutral" as const,
|
||||
iconName: "Users" as const,
|
||||
description: "Total registered clients",
|
||||
description: "Active clients",
|
||||
},
|
||||
{
|
||||
title: "Overdue Invoices",
|
||||
title: "Overdue",
|
||||
value: stats.overdueCount.toString(),
|
||||
numericValue: stats.overdueCount,
|
||||
isCurrency: false,
|
||||
change: "0", // TODO: Calculate overdue change if needed
|
||||
change: "0",
|
||||
trend: "neutral" as const,
|
||||
iconName: "TrendingDown" as const,
|
||||
description: "Invoices past due date",
|
||||
description: "Past due date",
|
||||
},
|
||||
];
|
||||
|
||||
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) => (
|
||||
<AnimatedStatsCard
|
||||
key={stat.title}
|
||||
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Charts section
|
||||
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();
|
||||
|
||||
function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Revenue Trend Chart */}
|
||||
<DashboardGrid className="lg:grid-cols-2">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Revenue Over Time
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={BarChart3}>
|
||||
Revenue over time
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Status Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Invoice Status
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>
|
||||
Invoice status
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceStatusChart invoices={invoices} />
|
||||
<InvoiceStatusChart data={stats.statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monthly Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Monthly Metrics
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Calendar}>
|
||||
Monthly metrics
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MonthlyMetricsChart invoices={invoices} />
|
||||
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardGrid>
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced Quick Actions
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{
|
||||
title: "Create Invoice",
|
||||
title: "Create invoice",
|
||||
description: "Start a new invoice for a client",
|
||||
href: "/dashboard/invoices/new",
|
||||
icon: FileText,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Add Client",
|
||||
description: "Register a new client",
|
||||
title: "Add client",
|
||||
description: "Register someone you bill",
|
||||
href: "/dashboard/clients/new",
|
||||
icon: Users,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "View All Invoices",
|
||||
description: "Manage your invoice pipeline",
|
||||
title: "View invoices",
|
||||
description: "Browse your full pipeline",
|
||||
href: "/dashboard/invoices",
|
||||
icon: BarChart3,
|
||||
featured: false,
|
||||
@@ -178,27 +181,36 @@ function QuickActions() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Quick Actions
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-2">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link
|
||||
key={action.title}
|
||||
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
|
||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||
: "border-border bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
: "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">
|
||||
<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">
|
||||
{action.description}
|
||||
</p>
|
||||
@@ -211,204 +223,168 @@ function QuickActions() {
|
||||
);
|
||||
}
|
||||
|
||||
// Current work section with enhanced design
|
||||
async function CurrentWork() {
|
||||
const invoices = await api.invoices.getAll();
|
||||
const draftInvoices = invoices.filter(
|
||||
(invoice) =>
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "draft",
|
||||
);
|
||||
const currentInvoice = draftInvoices[0];
|
||||
|
||||
if (!currentInvoice) {
|
||||
function CurrentWork({
|
||||
currentDraft,
|
||||
}: {
|
||||
currentDraft: DashboardStats["currentDraft"];
|
||||
}) {
|
||||
if (!currentDraft) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Current Work
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>
|
||||
Current work
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center">
|
||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<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">
|
||||
<CardContent className="flex flex-col items-center py-8 text-center">
|
||||
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="font-medium">No draft in progress</p>
|
||||
<CardDescription className="mt-1 max-w-xs">
|
||||
Start an invoice when you'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
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours =
|
||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
const totalHours = currentDraft.totalHours;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Current Work
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">In Progress</Badge>
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-lg font-semibold break-words">
|
||||
#{currentInvoice.invoiceNumber}
|
||||
</h3>
|
||||
<span className="text-primary text-2xl font-bold">
|
||||
${currentInvoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{currentDraft.client?.name}
|
||||
</p>
|
||||
</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">
|
||||
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||
${currentDraft.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{totalHours.toFixed(1)} hours logged
|
||||
</span>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover-lift flex-1"
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="hover-lift flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced recent activity
|
||||
async function RecentActivity({
|
||||
function RecentActivity({
|
||||
recentInvoices,
|
||||
}: {
|
||||
recentInvoices: RecentInvoice[];
|
||||
}) {
|
||||
// Use passed recentInvoices instead of fetching all
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return {
|
||||
backgroundColor: "oklch(var(--chart-2) / 0.1)",
|
||||
borderColor: "oklch(var(--chart-2) / 0.3)",
|
||||
color: "oklch(var(--chart-2))",
|
||||
};
|
||||
return "default" as const;
|
||||
case "sent":
|
||||
return {
|
||||
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
||||
borderColor: "oklch(var(--chart-1) / 0.3)",
|
||||
color: "oklch(var(--chart-1))",
|
||||
};
|
||||
return "secondary" as const;
|
||||
case "overdue":
|
||||
return {
|
||||
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
||||
borderColor: "oklch(var(--chart-3) / 0.3)",
|
||||
color: "oklch(var(--chart-3))",
|
||||
};
|
||||
return "destructive" as const;
|
||||
default:
|
||||
return {
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
borderColor: "hsl(var(--border))",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
};
|
||||
return "outline" as const;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Recent Activity
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Calendar}>
|
||||
Recent activity
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<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" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create your first invoice to get started
|
||||
</p>
|
||||
<Button asChild variant="outline" className="border-foreground/20">
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="font-medium">No invoices yet</p>
|
||||
<CardDescription className="mt-1 max-w-xs">
|
||||
Your latest invoices will show up here.
|
||||
</CardDescription>
|
||||
<Button asChild variant="outline" className="mt-5">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Invoice
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map((invoice, _index) => (
|
||||
<div className="space-y-2">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<Link
|
||||
key={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="flex items-start gap-3">
|
||||
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
||||
<div className="bg-muted rounded-xl p-2">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
#{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{invoice.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Badge style={getStatusStyle(invoice.status)}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
<span className="text-primary font-semibold">
|
||||
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{invoice.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStatusVariant(invoice.status)}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</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() {
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||
|
||||
// Fetch stats centrally
|
||||
const stats = await api.dashboard.getStats();
|
||||
void api.timeEntries.getRunning.prefetch();
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<DashboardPageLayout>
|
||||
<DashboardPageHeader
|
||||
title={`Welcome back, ${firstName}!`}
|
||||
description="Here's what's happening with your business today"
|
||||
title={`Welcome back, ${firstName}`}
|
||||
description="A snapshot of your invoices, revenue, and work in progress."
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats stats={stats} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
|
||||
<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>
|
||||
<DashboardGrid className="lg:grid-cols-2">
|
||||
<div className={cn(dashboardGridClass)}>
|
||||
<CurrentWork currentDraft={stats.currentDraft} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardGrid>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo, useState } from "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 { StatusBadge } from "~/components/data/status-badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -14,7 +15,12 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
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 { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
@@ -45,10 +51,14 @@ function toNumericChartValue(value: unknown) {
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [businessFilter, setBusinessFilter] = useState("all");
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||
api.invoices.getAll.useQuery();
|
||||
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 isLoading = invoicesLoading || expensesLoading;
|
||||
@@ -56,9 +66,14 @@ export default function ReportsPage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
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)
|
||||
const overviewData = useMemo(() => {
|
||||
if (!invoices.length) return null;
|
||||
if (!filteredInvoices.length) return null;
|
||||
|
||||
const now = new Date();
|
||||
const monthMap: Record<string, number> = {};
|
||||
@@ -72,7 +87,7 @@ export default function ReportsPage() {
|
||||
let totalPending = 0;
|
||||
let totalHours = 0;
|
||||
|
||||
for (const inv of invoices) {
|
||||
for (const inv of filteredInvoices) {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -96,7 +111,7 @@ export default function ReportsPage() {
|
||||
}));
|
||||
|
||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||
for (const inv of invoices) {
|
||||
for (const inv of filteredInvoices) {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -120,7 +135,7 @@ export default function ReportsPage() {
|
||||
paid: 0,
|
||||
overdue: 0,
|
||||
};
|
||||
for (const inv of invoices) {
|
||||
for (const inv of filteredInvoices) {
|
||||
const s = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -136,13 +151,13 @@ export default function ReportsPage() {
|
||||
totalHours,
|
||||
statusCount,
|
||||
};
|
||||
}, [invoices]);
|
||||
}, [filteredInvoices]);
|
||||
|
||||
// Tax summary for selected year
|
||||
const taxData = useMemo(() => {
|
||||
const year = parseInt(taxYear);
|
||||
|
||||
const yearInvoices = invoices.filter((inv) => {
|
||||
const yearInvoices = filteredInvoices.filter((inv) => {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -218,20 +233,20 @@ export default function ReportsPage() {
|
||||
yearInvoices,
|
||||
yearExpenses,
|
||||
};
|
||||
}, [invoices, expenses, taxYear]);
|
||||
}, [filteredInvoices, expenses, taxYear]);
|
||||
|
||||
const availableYears = useMemo(() => {
|
||||
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());
|
||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}, [invoices, expenses, currentYear]);
|
||||
}, [filteredInvoices, expenses, currentYear]);
|
||||
|
||||
const avgInvoice =
|
||||
invoices.length > 0
|
||||
filteredInvoices.length > 0
|
||||
? (overviewData?.totalRevenue ?? 0) /
|
||||
(invoices.filter(
|
||||
(filteredInvoices.filter(
|
||||
(i) =>
|
||||
getEffectiveInvoiceStatus(
|
||||
i.status as StoredInvoiceStatus,
|
||||
@@ -308,42 +323,57 @@ export default function ReportsPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Reports"
|
||||
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) => (
|
||||
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="overview">
|
||||
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tax">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Business</span>
|
||||
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="All businesses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All businesses</SelectItem>
|
||||
{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 ── */}
|
||||
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<PageTabsContent value="overview">
|
||||
<div className={dashboardStatGridClass}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -569,7 +599,7 @@ export default function ReportsPage() {
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{
|
||||
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
||||
width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -580,7 +610,7 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{invoices.length === 0 && (
|
||||
{filteredInvoices.length === 0 && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No invoices yet.
|
||||
</p>
|
||||
@@ -630,10 +660,10 @@ export default function ReportsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ── 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 gap-3">
|
||||
<span className="text-sm font-medium">Tax Year</span>
|
||||
@@ -840,8 +870,8 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</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 "acme-january"). 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,"API development",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>
|
||||
);
|
||||
}
|
||||
@@ -12,18 +12,16 @@ import {
|
||||
FileUp,
|
||||
Info,
|
||||
Key,
|
||||
Monitor,
|
||||
Palette,
|
||||
Shield,
|
||||
Upload,
|
||||
User,
|
||||
Users,
|
||||
Link as LinkIcon,
|
||||
Monitor,
|
||||
PanelLeft,
|
||||
Paintbrush,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import * as React from "react";
|
||||
@@ -70,11 +68,17 @@ import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
import { env } from "~/env";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
pageTabsGridClass,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -83,20 +87,25 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import {
|
||||
bodyFontPreferences,
|
||||
brand,
|
||||
colorModes,
|
||||
colorThemes,
|
||||
type ColorTheme,
|
||||
headingFontPreferences,
|
||||
interfaceThemes,
|
||||
radiusPreferences,
|
||||
sidebarStyles,
|
||||
themePresets,
|
||||
type InterfaceTheme,
|
||||
} from "~/lib/branding";
|
||||
import { brand, colorModes } from "~/lib/branding";
|
||||
import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
|
||||
import { ApiAccessSettings } from "./api-access-settings";
|
||||
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
||||
|
||||
const InvoiceImportPage = dynamic(
|
||||
() =>
|
||||
import("~/components/invoice-import-page").then(
|
||||
(module) => module.InvoiceImportPage,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="bg-muted/30 text-muted-foreground flex h-32 items-center justify-center rounded-lg border text-sm">
|
||||
Loading import tools...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const PdfPreviewFrame = dynamic(
|
||||
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
|
||||
@@ -110,79 +119,35 @@ const PdfPreviewFrame = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
function hslChannelsToHex(channels?: string) {
|
||||
const [hue, saturation, lightness] =
|
||||
channels?.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
|
||||
if (
|
||||
hue === undefined ||
|
||||
saturation === undefined ||
|
||||
lightness === undefined
|
||||
) {
|
||||
return "#16a34a";
|
||||
}
|
||||
|
||||
const s = saturation / 100;
|
||||
const l = lightness / 100;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
const [r, g, b] =
|
||||
hue < 60
|
||||
? [c, x, 0]
|
||||
: hue < 120
|
||||
? [x, c, 0]
|
||||
: hue < 180
|
||||
? [0, c, x]
|
||||
: hue < 240
|
||||
? [0, x, c]
|
||||
: hue < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x];
|
||||
|
||||
return `#${[r, g, b]
|
||||
.map((channel) =>
|
||||
Math.round((channel + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
function hexToHslChannels(hex: string) {
|
||||
const normalized = hex.replace("#", "");
|
||||
const red = parseInt(normalized.slice(0, 2), 16) / 255;
|
||||
const green = parseInt(normalized.slice(2, 4), 16) / 255;
|
||||
const blue = parseInt(normalized.slice(4, 6), 16) / 255;
|
||||
const max = Math.max(red, green, blue);
|
||||
const min = Math.min(red, green, blue);
|
||||
const lightness = (max + min) / 2;
|
||||
const delta = max - min;
|
||||
|
||||
if (delta === 0) {
|
||||
return `0 0% ${Number((lightness * 100).toFixed(1))}%`;
|
||||
}
|
||||
|
||||
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||||
const hue =
|
||||
max === red
|
||||
? 60 * (((green - blue) / delta) % 6)
|
||||
: max === green
|
||||
? 60 * ((blue - red) / delta + 2)
|
||||
: 60 * ((red - green) / delta + 4);
|
||||
|
||||
return `${Number(((hue + 360) % 360).toFixed(1))} ${Number(
|
||||
(saturation * 100).toFixed(1),
|
||||
)}% ${Number((lightness * 100).toFixed(1))}%`;
|
||||
}
|
||||
|
||||
function isFullHexColor(value: string) {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(value);
|
||||
}
|
||||
|
||||
export function SettingsContent() {
|
||||
const SETTINGS_TABS = ["general", "preferences", "data", "api"] as const;
|
||||
type SettingsTab = (typeof SETTINGS_TABS)[number];
|
||||
|
||||
function isSettingsTab(value: string | null | undefined): value is SettingsTab {
|
||||
return SETTINGS_TABS.includes(value as SettingsTab);
|
||||
}
|
||||
|
||||
export function SettingsContent({
|
||||
initialTab = "general",
|
||||
}: {
|
||||
initialTab?: SettingsTab;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get("tab");
|
||||
const activeTab = isSettingsTab(tabParam) ? tabParam : initialTab;
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (!isSettingsTab(value)) return;
|
||||
router.replace(`/dashboard/settings?tab=${value}`, { scroll: false });
|
||||
};
|
||||
|
||||
const { data: session } = useAuthSession();
|
||||
const [name, setName] = useState("");
|
||||
const [nameInitialized, setNameInitialized] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [importData, setImportData] = useState("");
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
@@ -197,43 +162,30 @@ export function SettingsContent() {
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||
const {
|
||||
interfaceTheme,
|
||||
bodyFontPreference,
|
||||
headingFontPreference,
|
||||
radiusPreference,
|
||||
sidebarStyle,
|
||||
colorMode,
|
||||
colorTheme,
|
||||
customColor,
|
||||
brandName,
|
||||
brandTagline,
|
||||
brandLogoText,
|
||||
brandIcon,
|
||||
pdfTemplate,
|
||||
pdfAccentColor,
|
||||
pdfFooterText,
|
||||
pdfShowLogo,
|
||||
pdfShowPageNumbers,
|
||||
updateAppearance,
|
||||
updateAppearanceDebounced,
|
||||
isUpdating: appearanceUpdating,
|
||||
} = useAppearance();
|
||||
const activePreset = themePresets[interfaceTheme];
|
||||
const themeModified =
|
||||
activePreset.bodyFontPreference !== bodyFontPreference ||
|
||||
activePreset.headingFontPreference !== headingFontPreference ||
|
||||
activePreset.colorTheme !== colorTheme ||
|
||||
activePreset.radiusPreference !== radiusPreference ||
|
||||
activePreset.sidebarStyle !== sidebarStyle ||
|
||||
activePreset.pdfTemplate !== pdfTemplate ||
|
||||
activePreset.pdfAccentColor !== pdfAccentColor;
|
||||
const customColorValue = customColor ?? "142.1 76.2% 36.3%";
|
||||
const selectAccent = (nextColorTheme: ColorTheme) => {
|
||||
updateAppearance({
|
||||
colorTheme: nextColorTheme,
|
||||
...(nextColorTheme === "custom" ? { customColor: customColorValue } : {}),
|
||||
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
|
||||
useAppearance();
|
||||
const utils = api.useUtils();
|
||||
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
|
||||
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.settings.getPdfSettings.invalidate();
|
||||
toast.success("Invoice PDF settings updated");
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
toast.error(`Failed to update PDF settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const savePdfSettings = (patch: {
|
||||
pdfTemplate?: PdfTemplate;
|
||||
pdfAccentColor?: string;
|
||||
pdfFontFamily?: PdfFontFamily;
|
||||
pdfNumericFontFamily?: PdfFontFamily;
|
||||
pdfFooterText?: string;
|
||||
pdfShowLogo?: boolean;
|
||||
pdfShowPageNumbers?: boolean;
|
||||
}) => {
|
||||
updatePdfSettingsMutation.mutate(patch);
|
||||
};
|
||||
|
||||
const handleLinkAuthentik = async () => {
|
||||
@@ -269,7 +221,7 @@ export function SettingsContent() {
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: profile, refetch: refetchProfile } =
|
||||
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
|
||||
api.settings.getProfile.useQuery();
|
||||
const isAdmin = profile?.role === "admin";
|
||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||
@@ -320,8 +272,9 @@ export function SettingsContent() {
|
||||
|
||||
const importDataMutation = api.settings.importData.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const { imported } = result;
|
||||
toast.success(
|
||||
`Data imported successfully! Added ${result.imported.clients} clients, ${result.imported.businesses} businesses, and ${result.imported.invoices} invoices.`,
|
||||
`Data imported successfully! Added ${imported.clients} clients, ${imported.businesses} businesses, ${imported.invoices} invoices, ${imported.expenses} expenses, ${imported.timeEntries} time entries, and ${imported.recurringInvoices} recurring invoices.`,
|
||||
);
|
||||
setImportData("");
|
||||
setIsImportDialogOpen(false);
|
||||
@@ -456,16 +409,13 @@ export function SettingsContent() {
|
||||
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
|
||||
};
|
||||
|
||||
// Set initial name value when profile loads
|
||||
// Set initial name value once when profile loads
|
||||
React.useEffect(() => {
|
||||
if (profile?.name && !name) {
|
||||
if (nameInitialized || !profileFetched) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
|
||||
setName(profile.name);
|
||||
}
|
||||
if (session?.user) {
|
||||
setName(session.user.name ?? "");
|
||||
}
|
||||
}, [session, profile?.name, name]);
|
||||
setName(profile?.name ?? session?.user?.name ?? "");
|
||||
setNameInitialized(true);
|
||||
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
|
||||
|
||||
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
||||
|
||||
@@ -494,16 +444,16 @@ export function SettingsContent() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="bg-muted/50 grid w-full grid-cols-4">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
||||
<TabsTrigger value="data">Data</TabsTrigger>
|
||||
<TabsTrigger value="api">API</TabsTrigger>
|
||||
</TabsList>
|
||||
<PageTabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="general">General</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
|
||||
<PageTabsTrigger value="data">Data</PageTabsTrigger>
|
||||
<PageTabsTrigger value="api">API</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<PageTabsContent value="general">
|
||||
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||
{/* Profile Section */}
|
||||
<Card className="form-section bg-card border-border border">
|
||||
<CardHeader>
|
||||
@@ -724,9 +674,9 @@ export function SettingsContent() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="preferences" className="space-y-8">
|
||||
<PageTabsContent value="preferences">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
@@ -734,152 +684,14 @@ export function SettingsContent() {
|
||||
Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select the app skin, color mode, accent, and font stack.
|
||||
Choose light, dark, or match your system setting.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{!isAdmin ? (
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Platform appearance and branding are managed by an
|
||||
administrator.
|
||||
</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Brand</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Public-facing name, logo text, and short product tagline.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Brand Name</Label>
|
||||
<Input
|
||||
value={brandName}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
brandName: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Logo Text</Label>
|
||||
<Input
|
||||
value={brandLogoText}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
brandLogoText: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Brand Icon</Label>
|
||||
<Input
|
||||
value={brandIcon}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
brandIcon: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Tagline</Label>
|
||||
<Input
|
||||
value={brandTagline}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
brandTagline: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t pt-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Theme</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Presets establish the broad visual language; color mode and
|
||||
accent can still be tuned independently.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
Theme Preset
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{themeModified && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
modified
|
||||
</Badge>
|
||||
)}
|
||||
{themeModified && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => updateAppearance(activePreset)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={interfaceTheme}
|
||||
onValueChange={(value) => {
|
||||
const nextTheme = value as InterfaceTheme;
|
||||
updateAppearance(themePresets[nextTheme]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{interfaceThemes.map((themeOption) => (
|
||||
<SelectItem
|
||||
key={themeOption.value}
|
||||
value={themeOption.value}
|
||||
>
|
||||
{themeOption.label}
|
||||
{themeOption.value === interfaceTheme &&
|
||||
themeModified
|
||||
? " (modified)"
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Applies the theme, fonts, accent, corner radius,
|
||||
navigation chrome, and PDF defaults.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
interfaceThemes.find(
|
||||
(themeOption) => themeOption.value === interfaceTheme,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Color Mode
|
||||
Color mode
|
||||
</Label>
|
||||
<Select
|
||||
value={colorMode}
|
||||
@@ -911,271 +723,10 @@ export function SettingsContent() {
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t pt-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Typography</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Body and heading fonts are separate so white-label installs
|
||||
can feel native without losing hierarchy.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4" />
|
||||
Body Font
|
||||
</Label>
|
||||
<Select
|
||||
value={bodyFontPreference}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
bodyFontPreference:
|
||||
value as typeof bodyFontPreference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bodyFontPreferences.map((fontOption) => (
|
||||
<SelectItem
|
||||
key={fontOption.value}
|
||||
value={fontOption.value}
|
||||
>
|
||||
{fontOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
bodyFontPreferences.find(
|
||||
(fontOption) =>
|
||||
fontOption.value === bodyFontPreference,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4" />
|
||||
Heading Font
|
||||
</Label>
|
||||
<Select
|
||||
value={headingFontPreference}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
headingFontPreference:
|
||||
value as typeof headingFontPreference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{headingFontPreferences.map((fontOption) => (
|
||||
<SelectItem
|
||||
key={fontOption.value}
|
||||
value={fontOption.value}
|
||||
>
|
||||
{fontOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
headingFontPreferences.find(
|
||||
(fontOption) =>
|
||||
fontOption.value === headingFontPreference,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t pt-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Color</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Accent controls primary actions, focus rings, and branded
|
||||
highlights.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label>Accent</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{colorThemes.map((themeOption) => (
|
||||
<button
|
||||
key={themeOption.value}
|
||||
type="button"
|
||||
onClick={() => selectAccent(themeOption.value)}
|
||||
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
|
||||
colorTheme === themeOption.value
|
||||
? "border-primary bg-muted text-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="size-4 rounded-full border"
|
||||
style={{ backgroundColor: themeOption.swatch }}
|
||||
/>
|
||||
{themeOption.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectAccent("custom")}
|
||||
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
|
||||
colorTheme === "custom"
|
||||
? "border-primary bg-muted text-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="size-4 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: customColor
|
||||
? `hsl(${customColor})`
|
||||
: "hsl(142.1 76.2% 36.3%)",
|
||||
}}
|
||||
/>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{colorTheme === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<InputColor
|
||||
label="Custom Accent"
|
||||
value={hslChannelsToHex(customColorValue)}
|
||||
onBlur={() => undefined}
|
||||
onChange={(value) => {
|
||||
if (isFullHexColor(value)) {
|
||||
updateAppearanceDebounced({
|
||||
colorTheme: "custom",
|
||||
customColor: hexToHslChannels(value),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
<Input
|
||||
value={customColorValue}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
colorTheme: "custom",
|
||||
customColor: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="142.1 76.2% 36.3%"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Custom values use HSL channels, for example 142.1 76.2%
|
||||
36.3%.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t pt-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Layout</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Control global rounding and whether navigation floats or
|
||||
sits flush with the viewport.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
Corner Radius
|
||||
</Label>
|
||||
<Select
|
||||
value={radiusPreference}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
radiusPreference: value as typeof radiusPreference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{radiusPreferences.map((radiusOption) => (
|
||||
<SelectItem
|
||||
key={radiusOption.value}
|
||||
value={radiusOption.value}
|
||||
>
|
||||
{radiusOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
radiusPreferences.find(
|
||||
(radiusOption) =>
|
||||
radiusOption.value === radiusPreference,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
Navigation Chrome
|
||||
</Label>
|
||||
<Select
|
||||
value={sidebarStyle}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
sidebarStyle: value as typeof sidebarStyle,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sidebarStyles.map((styleOption) => (
|
||||
<SelectItem
|
||||
key={styleOption.value}
|
||||
value={styleOption.value}
|
||||
>
|
||||
{styleOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
sidebarStyles.find(
|
||||
(styleOption) => styleOption.value === sidebarStyle,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{appearanceUpdating && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Saving appearance...
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">Saving...</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{isAdmin && (
|
||||
@@ -1191,7 +742,12 @@ export function SettingsContent() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||
<div
|
||||
className={cn(
|
||||
pageTabsGridClass,
|
||||
"xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="space-y-2">
|
||||
@@ -1200,12 +756,13 @@ export function SettingsContent() {
|
||||
PDF Template
|
||||
</Label>
|
||||
<Select
|
||||
value={pdfTemplate}
|
||||
value={pdfSettings?.pdfTemplate ?? "classic"}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
pdfTemplate: value as typeof pdfTemplate,
|
||||
savePdfSettings({
|
||||
pdfTemplate: value as PdfTemplate,
|
||||
})
|
||||
}
|
||||
disabled={updatePdfSettingsMutation.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -1224,29 +781,93 @@ export function SettingsContent() {
|
||||
<div className="space-y-2">
|
||||
<InputColor
|
||||
label="PDF Accent"
|
||||
value={pdfAccentColor}
|
||||
value={pdfSettings?.pdfAccentColor ?? "#111827"}
|
||||
onBlur={() => undefined}
|
||||
onChange={(value) => {
|
||||
if (isFullHexColor(value)) {
|
||||
updateAppearance({
|
||||
pdfAccentColor: value,
|
||||
});
|
||||
savePdfSettings({ pdfAccentColor: value });
|
||||
}
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Body Text Font</Label>
|
||||
<Select
|
||||
value={pdfSettings?.pdfFontFamily ?? "sans"}
|
||||
onValueChange={(value) =>
|
||||
savePdfSettings({
|
||||
pdfFontFamily: value as PdfFontFamily,
|
||||
})
|
||||
}
|
||||
disabled={updatePdfSettingsMutation.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pdfFontFamilyOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
pdfFontFamilyOptions.find(
|
||||
(option) =>
|
||||
option.value ===
|
||||
(pdfSettings?.pdfFontFamily ?? "sans"),
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Numbers Font</Label>
|
||||
<Select
|
||||
value={pdfSettings?.pdfNumericFontFamily ?? "mono"}
|
||||
onValueChange={(value) =>
|
||||
savePdfSettings({
|
||||
pdfNumericFontFamily: value as PdfFontFamily,
|
||||
})
|
||||
}
|
||||
disabled={updatePdfSettingsMutation.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pdfFontFamilyOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Used for dates, hours, rates, and totals.{" "}
|
||||
{
|
||||
pdfFontFamilyOptions.find(
|
||||
(option) =>
|
||||
option.value ===
|
||||
(pdfSettings?.pdfNumericFontFamily ?? "mono"),
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Footer Text</Label>
|
||||
<Input
|
||||
value={pdfFooterText}
|
||||
value={pdfSettings?.pdfFooterText ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
pdfFooterText: event.target.value,
|
||||
})
|
||||
savePdfSettings({ pdfFooterText: event.target.value })
|
||||
}
|
||||
disabled={updatePdfSettingsMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1259,10 +880,11 @@ export function SettingsContent() {
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pdfShowLogo}
|
||||
checked={pdfSettings?.pdfShowLogo ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppearance({ pdfShowLogo: Boolean(checked) })
|
||||
savePdfSettings({ pdfShowLogo: Boolean(checked) })
|
||||
}
|
||||
disabled={updatePdfSettingsMutation.isPending}
|
||||
aria-label="Toggle PDF logo"
|
||||
/>
|
||||
</div>
|
||||
@@ -1275,12 +897,13 @@ export function SettingsContent() {
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pdfShowPageNumbers}
|
||||
checked={pdfSettings?.pdfShowPageNumbers ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppearance({
|
||||
savePdfSettings({
|
||||
pdfShowPageNumbers: Boolean(checked),
|
||||
})
|
||||
}
|
||||
disabled={updatePdfSettingsMutation.isPending}
|
||||
aria-label="Toggle PDF page numbers"
|
||||
/>
|
||||
</div>
|
||||
@@ -1288,13 +911,17 @@ export function SettingsContent() {
|
||||
</div>
|
||||
|
||||
<PdfPreviewFrame
|
||||
businessName={brandName}
|
||||
businessName={brand.name}
|
||||
settings={{
|
||||
pdfTemplate,
|
||||
pdfAccentColor,
|
||||
pdfFooterText,
|
||||
pdfShowLogo,
|
||||
pdfShowPageNumbers,
|
||||
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
|
||||
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
|
||||
pdfFontFamily: pdfSettings?.pdfFontFamily ?? "sans",
|
||||
pdfNumericFontFamily:
|
||||
pdfSettings?.pdfNumericFontFamily ?? "mono",
|
||||
pdfFooterText:
|
||||
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
|
||||
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
|
||||
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers ?? true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1397,9 +1024,9 @@ export function SettingsContent() {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="data" className="space-y-8">
|
||||
<PageTabsContent value="data">
|
||||
{/* Data Overview */}
|
||||
<Card className="form-section bg-card border-border border">
|
||||
<CardHeader>
|
||||
@@ -1620,8 +1247,29 @@ export function SettingsContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Import Invoices */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileUp className="text-primary h-5 w-5" />
|
||||
Import Invoices
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Upload CSV or JSON files to create draft invoices in bulk
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ImportPageHeaderActions />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceImportPage />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Account (Danger Zone) */}
|
||||
<Card className="border-destructive/50 bg-destructive/5 border">
|
||||
<Card className="bg-card border-destructive/50 border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
@@ -1672,11 +1320,11 @@ export function SettingsContent() {
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="api" className="space-y-8">
|
||||
<PageTabsContent value="api">
|
||||
<ApiAccessSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
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 { 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 (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Settings"
|
||||
description="Manage your account preferences and data"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<SettingsContent />
|
||||
<SettingsContent initialTab={initialTab} />
|
||||
</Suspense>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
|
||||
export default async function TimeClockPage({
|
||||
@@ -17,7 +18,7 @@ export default async function TimeClockPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Time clock"
|
||||
description="Track billable hours and save them directly to an invoice"
|
||||
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
|
||||
defaultInvoiceId={params.invoiceId}
|
||||
/>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function formatDate(date: Date) {
|
||||
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||
<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>
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900 shrink-0">
|
||||
|
||||
+25
-52
@@ -4,21 +4,33 @@ import { type Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import {
|
||||
brand,
|
||||
defaultBodyFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
defaultSidebarStyle,
|
||||
} from "~/lib/branding";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
|
||||
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${brand.name} - Invoicing Made Simple`,
|
||||
metadataBase: new URL(getAppUrl()),
|
||||
title: {
|
||||
default: siteTitle,
|
||||
template: `%s | ${brand.name}`,
|
||||
},
|
||||
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" }],
|
||||
};
|
||||
|
||||
@@ -34,23 +46,6 @@ const playfair = localFont({
|
||||
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({
|
||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-mono",
|
||||
@@ -64,14 +59,8 @@ export default function RootLayout({
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
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-theme="slate"
|
||||
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
|
||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
@@ -79,27 +68,11 @@ export default function RootLayout({
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
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 appearance = Object.assign(defaults, stored);
|
||||
var colorMode = stored.colorMode || "system";
|
||||
var root = document.documentElement;
|
||||
root.dataset.interfaceTheme = appearance.interfaceTheme;
|
||||
root.dataset.bodyFont = appearance.bodyFontPreference;
|
||||
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);
|
||||
root.dataset.colorMode = colorMode;
|
||||
if (colorMode === "dark") root.classList.add("dark");
|
||||
} catch {}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import { brand, splitLogoText } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
@@ -11,24 +10,8 @@ interface LogoProps {
|
||||
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) {
|
||||
const appearance = useAppearance();
|
||||
const logoText = appearance.brandLogoText || brand.logoText;
|
||||
const icon = appearance.brandIcon || brand.icon;
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||
const sizeClasses = {
|
||||
sm: "text-base",
|
||||
md: "text-xl",
|
||||
@@ -45,7 +28,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
sizeClasses={sizeClasses}
|
||||
logoPrefix={logoPrefix}
|
||||
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" }}
|
||||
className="text-primary font-bold tracking-tight"
|
||||
>
|
||||
{icon}
|
||||
{brand.icon}
|
||||
</motion.span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
@@ -75,8 +58,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
||||
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
|
||||
></motion.span>
|
||||
className="inline-block w-1"
|
||||
/>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -125,7 +108,7 @@ function LogoContent({
|
||||
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
<span className="inline-block w-1"></span>
|
||||
<span className="inline-block w-1" />
|
||||
<span className="text-foreground font-bold tracking-tight">
|
||||
{logoPrefix}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,886 +1,2 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
/** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
|
||||
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
ChevronsRight,
|
||||
Filter,
|
||||
Search,
|
||||
SearchX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
|
||||
clearSelection: () => void,
|
||||
) => React.ReactNode;
|
||||
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>({
|
||||
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
|
||||
onRowClick,
|
||||
selectionActions,
|
||||
initialSorting = [],
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyIcon,
|
||||
emptyAction,
|
||||
filteredEmptyTitle = "No matches for your search",
|
||||
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
|
||||
}, [globalFilter]);
|
||||
|
||||
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
|
||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<p className="text-muted-foreground">No results found</p>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
{isDatasetEmpty && emptyTitle ? (
|
||||
<DataTableEmptyState
|
||||
icon={emptyIcon}
|
||||
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>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -155,11 +155,22 @@ export function InvoiceList() {
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{invoice.status === "draft" ? (
|
||||
<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" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
“{receipt.originalFilename}” 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
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 { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -106,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Fetch business data if editing
|
||||
const { data: business, isLoading: isLoadingBusiness } =
|
||||
api.businesses.getById.useQuery(
|
||||
{ id: businessId! },
|
||||
{ enabled: mode === "edit" && !!businessId },
|
||||
{
|
||||
enabled: mode === "edit" && !!businessId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch email configuration if editing
|
||||
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
||||
api.businesses.getEmailConfig.useQuery(
|
||||
{ id: businessId! },
|
||||
{ enabled: mode === "edit" && !!businessId },
|
||||
{
|
||||
enabled: mode === "edit" && !!businessId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Update email configuration mutation
|
||||
@@ -140,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Load business data when editing
|
||||
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.
|
||||
setFormData({
|
||||
name: business.name,
|
||||
@@ -162,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
resendDomain: emailConfig?.resendDomain ?? "",
|
||||
emailFromName: emailConfig?.emailFromName ?? "",
|
||||
});
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [business, emailConfig, mode]);
|
||||
}, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
@@ -408,7 +430,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
(mode === "edit" && isLoadingEmailConfig)
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<DashboardPage className="pb-32">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
@@ -430,21 +452,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={mode === "edit" ? "Edit Business" : "Add Business"}
|
||||
description={
|
||||
mode === "edit"
|
||||
? "Update business information below"
|
||||
: "Enter business details below to add a new business."
|
||||
}
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -469,9 +490,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
@@ -902,7 +927,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
|
||||
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
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 { api } from "~/trpc/react";
|
||||
import {
|
||||
@@ -88,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Fetch client data if editing
|
||||
const { data: client, isLoading: isLoadingClient } =
|
||||
api.clients.getById.useQuery(
|
||||
{ id: clientId! },
|
||||
{ enabled: mode === "edit" && !!clientId },
|
||||
{
|
||||
enabled: mode === "edit" && !!clientId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const createClient = api.clients.create.useMutation({
|
||||
@@ -116,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Load client data when editing
|
||||
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.
|
||||
setFormData({
|
||||
name: client.name,
|
||||
@@ -133,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||
currency: client.currency ?? "USD",
|
||||
});
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [client, mode]);
|
||||
}, [client, mode, initialized]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | number | null) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
@@ -237,7 +251,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
|
||||
if (mode === "edit" && isLoadingClient) {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<DashboardPage className="pb-32">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
@@ -259,21 +273,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
||||
description={
|
||||
mode === "edit"
|
||||
? "Update client information below"
|
||||
: "Enter client details below to add a new client."
|
||||
}
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -298,9 +311,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
@@ -508,7 +525,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
subject: string;
|
||||
@@ -53,7 +55,7 @@ export function EmailPreview({
|
||||
const calculateTotal = () => {
|
||||
if (!invoice?.items) return 0;
|
||||
const subtotal = invoice.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
@@ -82,17 +84,14 @@ export function EmailPreview({
|
||||
description: item.description ?? "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount ?? item.hours * item.rate,
|
||||
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||
})) ?? [],
|
||||
},
|
||||
customContent: content,
|
||||
customMessage: customMessage,
|
||||
userName: invoice.business?.name ?? "Your Business",
|
||||
userEmail: fromEmail,
|
||||
baseUrl:
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://beenvoice.app",
|
||||
baseUrl: getAppUrl(),
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ export function FileUpload({
|
||||
<div
|
||||
{...getRootProps()}
|
||||
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",
|
||||
isDragActive && "border-primary/40 bg-primary/10",
|
||||
isDragReject && "border-destructive/40 bg-destructive/10",
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
|
||||
Total
|
||||
</span>
|
||||
<span className="text-primary text-lg font-bold">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
import * as React 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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -17,7 +26,6 @@ import {
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { InvoiceLineItems } from "./invoice-line-items";
|
||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||
import { EmailPreview } from "./email-preview";
|
||||
@@ -52,6 +60,11 @@ import {
|
||||
import { STATUS_OPTIONS } from "./invoice/types";
|
||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||
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 { CountUp } from "~/components/ui/count-up";
|
||||
@@ -62,19 +75,17 @@ interface InvoiceFormProps {
|
||||
|
||||
function InvoiceFormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
<PageHeader
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice form"
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
|
||||
{/* Tabs Skeleton */}
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
|
||||
<div className={cn(pageTabsGridClass, "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>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +106,7 @@ function plainTextToHtml(value: string) {
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
return {
|
||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||
invoicePrefix: "#",
|
||||
@@ -109,9 +120,7 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
defaultHourlyRate: null,
|
||||
items: blank
|
||||
? []
|
||||
: [
|
||||
items: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
@@ -119,6 +128,7 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -126,13 +136,11 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||
|
||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isBlank = searchParams.get("blank") === "1";
|
||||
const utils = api.useUtils();
|
||||
|
||||
// State
|
||||
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||
createDefaultInvoiceFormData(isBlank),
|
||||
createDefaultInvoiceFormData(),
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -140,15 +148,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
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)
|
||||
const { data: clients, isLoading: loadingClients } =
|
||||
@@ -179,7 +178,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
}, [invoiceId]);
|
||||
useEffect(() => {
|
||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||
// ... (Mapping logic same as before)
|
||||
const mappedItems: InvoiceItem[] =
|
||||
existingInvoice.items?.map((item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -188,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
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.
|
||||
setFormData({
|
||||
@@ -214,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -231,9 +231,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
}
|
||||
}, [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 subtotal = formData.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||
@@ -263,6 +276,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: item.description || "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
}),
|
||||
[formData],
|
||||
@@ -293,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
hours: 1,
|
||||
rate: prev.defaultHourlyRate ?? 0,
|
||||
amount: prev.defaultHourlyRate ?? 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -308,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: parsed.description,
|
||||
hours: parsed.hours ?? 1,
|
||||
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) => ({
|
||||
...prev,
|
||||
items: prev.items.map((item, i) => {
|
||||
if (i === idx) {
|
||||
if (i !== idx) return item;
|
||||
|
||||
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
||||
const next = applyBillingTypeChange(value, item);
|
||||
return {
|
||||
...item,
|
||||
...next,
|
||||
billingType: value,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = { ...item, [field]: value };
|
||||
if (field === "hours" || field === "rate") {
|
||||
updated.amount = updated.hours * updated.rate;
|
||||
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
|
||||
updated.billingType = getLineItemBillingType(updated.hours);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
@@ -430,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
amount: i.hours * i.rate,
|
||||
amount: calculateLineItemAmount(i.hours, i.rate),
|
||||
})),
|
||||
};
|
||||
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
||||
@@ -456,27 +484,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
!initialized ||
|
||||
loadingClients ||
|
||||
loadingBusinesses ||
|
||||
(invoiceId && invoiceId !== "new" && loadingInvoice)
|
||||
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
|
||||
(invoiceId &&
|
||||
invoiceId !== "new" &&
|
||||
existingInvoice &&
|
||||
existingInvoice.status !== "draft")
|
||||
)
|
||||
return <InvoiceFormSkeleton />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter space-y-6 pb-8">
|
||||
<PageHeader
|
||||
title={
|
||||
invoiceId !== "new"
|
||||
? "Edit Invoice"
|
||||
: isBlank
|
||||
? "Blank Invoice"
|
||||
: "Create Invoice"
|
||||
}
|
||||
description={
|
||||
isBlank
|
||||
? "Set up a draft to clock time into later"
|
||||
: "Manage your invoice"
|
||||
}
|
||||
variant="gradient"
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||
description="Manage your invoice"
|
||||
>
|
||||
{invoiceId !== "new" && (
|
||||
<Button
|
||||
@@ -491,43 +512,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
>
|
||||
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>
|
||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
{/* DETAILS TAB */}
|
||||
<TabsContent
|
||||
value="details"
|
||||
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
||||
>
|
||||
<PageTabsContent value="details">
|
||||
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2 text-base">
|
||||
@@ -770,14 +767,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ITEMS TAB */}
|
||||
<TabsContent
|
||||
value="items"
|
||||
className="mt-6 focus-visible:outline-none"
|
||||
>
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<PageTabsContent value="items">
|
||||
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<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="font-mono text-xl font-semibold">
|
||||
<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"
|
||||
/>
|
||||
</span>
|
||||
@@ -826,13 +824,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* TIMESHEET TAB */}
|
||||
<TabsContent
|
||||
value="timesheet"
|
||||
className="mt-6 focus-visible:outline-none"
|
||||
>
|
||||
<PageTabsContent value="timesheet">
|
||||
<Card className="min-h-[600px] w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
@@ -850,44 +845,51 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="preview"
|
||||
className="mt-6 focus-visible:outline-none"
|
||||
>
|
||||
<Tabs
|
||||
value={previewTab}
|
||||
onValueChange={setPreviewTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
|
||||
<TabsTrigger
|
||||
value="pdf"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
<PageTabsContent value="preview">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
Preview
|
||||
</CardTitle>
|
||||
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTab("pdf")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
previewTab === "pdf"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
PDF
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="email"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTab("email")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
previewTab === "email"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pdf" className="mt-6">
|
||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="email" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
<Mail className="h-5 w-5" /> Email Preview
|
||||
</CardTitle>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{previewTab === "pdf" ? (
|
||||
<InvoicePdfPreviewPanel
|
||||
embedded
|
||||
input={pdfPreviewInput}
|
||||
enabled={activeTab === "preview" && previewTab === "pdf"}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-t p-6">
|
||||
<EmailPreview
|
||||
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||
selectedBusiness?.name ?? "Your Business"
|
||||
@@ -922,35 +924,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<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>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</DashboardPage>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
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 {
|
||||
useLineItemSuggestions,
|
||||
type LineItemSuggestion,
|
||||
} from "~/hooks/use-line-item-suggestions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -23,6 +35,7 @@ interface InvoiceItem {
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
billingType?: LineItemBillingType;
|
||||
}
|
||||
|
||||
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>(
|
||||
({ 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 (
|
||||
<div
|
||||
ref={ref}
|
||||
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
|
||||
@@ -177,6 +198,25 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={billingType}
|
||||
onValueChange={(value: LineItemBillingType) =>
|
||||
onUpdate(index, "billingType", value)
|
||||
}
|
||||
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)}
|
||||
@@ -187,6 +227,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
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">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
${lineTotal.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{!readOnly ? (
|
||||
@@ -235,6 +276,10 @@ function MobileLineItem({
|
||||
onDescriptionChange,
|
||||
readOnly,
|
||||
}: LineItemRowProps) {
|
||||
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||
const isFixed = billingType === "fixed";
|
||||
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`invoice-item-${index}-mobile`}
|
||||
@@ -260,10 +305,26 @@ function MobileLineItem({
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
className="w-[92px] shrink-0"
|
||||
className="w-auto shrink-0"
|
||||
inputClassName="h-8 px-2 text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Select
|
||||
value={billingType}
|
||||
onValueChange={(value: LineItemBillingType) =>
|
||||
onUpdate(index, "billingType", value)
|
||||
}
|
||||
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)}
|
||||
@@ -274,6 +335,7 @@ function MobileLineItem({
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : null}
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
@@ -285,7 +347,7 @@ function MobileLineItem({
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
${lineTotal.toFixed(2)}
|
||||
</span>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
|
||||
onUpdateItem(index, "description", s.description);
|
||||
onUpdateItem(index, "hours", s.hours);
|
||||
onUpdateItem(index, "rate", s.rate);
|
||||
onUpdateItem(index, "billingType", "hourly");
|
||||
setSuggestions([]);
|
||||
setQueriedIndex(null);
|
||||
}
|
||||
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
<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>Description</span>
|
||||
<span className="text-center">Type</span>
|
||||
<span className="text-center">Hours</span>
|
||||
<span className="text-center">Rate</span>
|
||||
<span className="text-right">Amount</span>
|
||||
|
||||
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
heightClassName?: string;
|
||||
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function InvoicePdfPreviewPanel({
|
||||
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
|
||||
enabled = true,
|
||||
className,
|
||||
heightClassName = "h-[min(80vh,760px)]",
|
||||
embedded = false,
|
||||
}: InvoicePdfPreviewPanelProps) {
|
||||
const previewReady = canPreview(input);
|
||||
|
||||
@@ -54,16 +57,7 @@ export function InvoicePdfPreviewPanel({
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF preview
|
||||
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
const previewBody = (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/20 overflow-hidden border-t",
|
||||
@@ -99,7 +93,22 @@ export function InvoicePdfPreviewPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF preview
|
||||
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{previewBody}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { type RouterOutputs } from "~/trpc/react";
|
||||
export type ClientType = RouterOutputs["clients"]["getAll"][number];
|
||||
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
||||
|
||||
import type { LineItemBillingType } from "~/lib/invoice-line-item";
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
@@ -10,6 +12,7 @@ export interface InvoiceItem {
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
billingType: LineItemBillingType;
|
||||
}
|
||||
|
||||
export interface InvoiceFormData {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
import {
|
||||
SidebarProvider,
|
||||
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { isCollapsed } = useSidebar();
|
||||
const { sidebarStyle } = useAppearance();
|
||||
const pathname = usePathname();
|
||||
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
||||
const isOnboarding = pathname === "/dashboard/onboarding";
|
||||
|
||||
return (
|
||||
<div className="bg-dashboard relative flex min-h-screen">
|
||||
{/* Desktop Sidebar */}
|
||||
{!isOnboarding && (
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar (Sheet) */}
|
||||
<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">
|
||||
<div
|
||||
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}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{/* Mobile Link / Logo */}
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||
<Logo size="sm" className="shrink-0" />
|
||||
<ActiveTimerWidget compact />
|
||||
</div>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<div className="sr-only">
|
||||
@@ -53,30 +60,20 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
|
||||
"md:ml-0",
|
||||
sidebarStyle === "floating"
|
||||
? isCollapsed
|
||||
? "md:ml-24"
|
||||
: "md:ml-[18rem]"
|
||||
: isCollapsed
|
||||
? "md:ml-16"
|
||||
: "md:ml-64",
|
||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
|
||||
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||
)}
|
||||
>
|
||||
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
||||
<div className="mb-4 md:hidden">
|
||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<ActiveTimerWidget />
|
||||
</div>
|
||||
{children}
|
||||
{isOnboarding ? (
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
) : (
|
||||
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</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
Reference in New Issue
Block a user