Compare commits

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:45:51 -04:00
106 changed files with 7949 additions and 2059 deletions
+41 -6
View File
@@ -4,14 +4,15 @@
# #
# Quick start (local dev): # Quick start (local dev):
# cp .env.example .env.local # cp .env.example .env.local
# docker compose -f docker-compose.dev.yml up -d # docker compose -f docker-compose.dev.yml up -d # Postgres + Garage
# bun run db:push # or: bun run db:migrate # bun run db:push # or: bun run db:migrate
# bun run dev # bun run dev
# Garage S3 API: http://localhost:3900
# #
# Quick start (Docker app + Postgres): # Quick start (Docker app + Postgres):
# cp .env.example .env # cp .env.example .env
# # edit AUTH_SECRET + public URLs below # # edit AUTH_SECRET + public URLs below
# docker compose up -d --build # ./scripts/docker-deploy.sh
# #
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Build-time vs runtime (Docker) # Build-time vs runtime (Docker)
@@ -31,7 +32,8 @@
# emails, and MCP links. In the browser, sign-in uses the current page origin # emails, and MCP links. In the browser, sign-in uses the current page origin
# automatically so dev works when Next picks another port (e.g. 3002). # automatically so dev works when Next picks another port (e.g. 3002).
# #
# Updating production: git pull && docker compose up -d --build # Updating production: git pull && ./scripts/docker-deploy.sh
# (or: docker compose up -d --build). Plain `docker compose up -d` does NOT rebuild.
# Migrations run on every app start (idempotent — only pending SQL is applied). # Migrations run on every app start (idempotent — only pending SQL is applied).
# ============================================================================= # =============================================================================
@@ -62,8 +64,9 @@ NODE_ENV=development
# Set true when connecting to local Postgres without SSL (default for compose). # Set true when connecting to local Postgres without SSL (default for compose).
DB_DISABLE_SSL=true DB_DISABLE_SSL=true
# Dev-only: host port for `docker compose -f docker-compose.dev.yml` Postgres. # Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
POSTGRES_PORT=5432 POSTGRES_PORT=5432
GARAGE_API_PORT=3900
# Optional: if Next dev picks another port, you do not need to change URLs for # Optional: if Next dev picks another port, you do not need to change URLs for
# sign-in — the auth client uses window.location.origin in the browser. # sign-in — the auth client uses window.location.origin in the browser.
@@ -75,6 +78,10 @@ POSTGRES_PORT=5432
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000). # Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
WEB_PORT=3000 WEB_PORT=3000
# App image tag for docker-compose.yml (optional). docker-deploy.sh sets
# beenvoice:<git-sha> automatically; default without it is beenvoice:local.
# BEENVOICE_IMAGE=beenvoice:local
# Postgres credentials for docker-compose.yml `db` service. # Postgres credentials for docker-compose.yml `db` service.
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost). # DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
POSTGRES_USER=postgres POSTGRES_USER=postgres
@@ -112,12 +119,40 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# Access control (optional) # Access control (optional)
# ============================================================================= # =============================================================================
# Block new email/password registrations. Use literal true or false. # Block new email/password registrations (default: true / signups off).
# DISABLE_SIGNUPS=true # Set DISABLE_SIGNUPS=false to allow new email/password signups.
# DISABLE_SIGNUPS=false
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron). # Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
# CRON_SECRET= # CRON_SECRET=
# =============================================================================
# Receipt storage — S3-compatible (optional)
# =============================================================================
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
# .data/receipts/ (dev-friendly). Works with AWS S3, Garage, Cloudflare R2, etc.
#
# S3_ENDPOINT — who can reach Garage?
# • Host dev (bun dev + docker-compose.dev.yml Garage on the host): localhost:3900
# • App in Docker (docker-compose.yml): http://garage:3900 (Compose service name)
# • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900
# with Connect to Predefined Network on both resources. Never bare "garage".
# - NEVER use localhost in production — inside the app container that is the app, not Garage.
#
# Local dev with docker-compose.dev.yml Garage (host `bun dev`):
S3_ENDPOINT=http://localhost:3900
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=GK3515373e4c851ebaad366558
S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
S3_REGION=garage
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
#
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
# ============================================================================= # =============================================================================
# SSO — Authentik OIDC (optional) # SSO — Authentik OIDC (optional)
# ============================================================================= # =============================================================================
+1
View File
@@ -24,6 +24,7 @@ next-env.d.ts
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.data/
# debug # debug
npm-debug.log* npm-debug.log*
-2
View File
@@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG BETTER_AUTH_URL=http://localhost:3000 ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile: # Low-memory Docker build profile:
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
# - skip tsc inside `next build` (run `bun run check` in CI instead) # - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \ ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=1 \
NODE_ENV=production \ NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \ SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \ NEXT_TELEMETRY_DISABLED=1 \
+42 -10
View File
@@ -82,13 +82,20 @@ bun run db:push # fast iteration during development
# bun run db:migrate # same migrations the Docker image runs in production # bun run db:migrate # same migrations the Docker image runs in production
``` ```
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`:
- Email: `demo@example.com`
- Password: `demo123`
The account includes a sample business, clients, and invoices (draft, sent, and paid).
### 4. Run ### 4. Run
```bash ```bash
bun run dev bun run dev
``` ```
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, then sign in. Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, or sign in with the demo account above.
## Docker deployment (app + database) ## Docker deployment (app + database)
@@ -125,29 +132,50 @@ docker compose build --no-cache app
### 2. First start (or after code changes) ### 2. First start (or after code changes)
```bash ```bash
docker compose up -d --build ./scripts/docker-deploy.sh
# or: bun run docker:deploy
# or: docker compose up -d --build
``` ```
`--build` is important. A plain `docker compose up -d` reuses the existing image and **does not** pick up new code from `git pull`. `--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:<sha>`) so each deploy gets a distinct image.
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network. App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### Scheduled recurring invoices
The app container does not run a cron daemon. It starts the web server with
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
when something calls `POST /api/cron/generate-recurring` with
`Authorization: Bearer $CRON_SECRET`.
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
external scheduler to call
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
### 3. Updating an existing deploy ### 3. Updating an existing deploy
```bash ```bash
git pull git pull
docker compose up -d --build # rebuild image, restart app, run any new migrations ./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
# or: docker compose up -d --build
``` ```
| Command | New code? | Migrations run? | | Command | New code? | Migrations run? |
|---------|-----------|-----------------| |---------|-----------|-----------------|
| `git pull` only | No | No | | `git pull` only | No | No |
| `docker compose up -d` (no `--build`) | No — old image | Only if the app container restarts (same image) | | `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
| `docker compose up -d --build` | Yes | Yes — on app container start | | `./scripts/docker-deploy.sh` or `docker compose up -d --build` | Yes | Yes — on app container start |
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) | | `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
To verify migration files match the journal before deploy: `bun run db:verify-journal`. To verify migration files match the journal before deploy: `bun run db:verify-journal`.
### Coolify
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
### 4. Sign-ups ### 4. Sign-ups
Registration is **enabled** by default. To block new email/password accounts: Registration is **enabled** by default. To block new email/password accounts:
@@ -178,7 +206,9 @@ beenvoice-web/
├── src/lib/ # auth, PDF, email, branding helpers ├── src/lib/ # auth, PDF, email, branding helpers
├── drizzle/ # SQL migrations ├── drizzle/ # SQL migrations
├── Dockerfile # Production image (migrate + next start) ├── Dockerfile # Production image (migrate + next start)
├── docker-compose.yml # App + Postgres (deploy) ├── docker-compose.yml # App + Postgres + Garage (deploy)
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
├── docker-compose.dev.yml # Postgres only (local dev) ├── docker-compose.dev.yml # Postgres only (local dev)
└── docs/ # Architecture and UI guides └── docs/ # Architecture and UI guides
``` ```
@@ -206,12 +236,13 @@ bun run lint:fix
bun run format:write bun run format:write
bun run typecheck bun run typecheck
# Docker helpers (Postgres only — uses Colima on macOS) # Docker helpers
bun run docker:up # colima start + docker-compose.dev.yml up -d bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
bun run docker:down # stop dev Postgres + colima bun run docker:down # stop dev Postgres + colima
bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d
``` ```
Full-stack deploy uses `docker compose up` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`. Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
## API surface ## API surface
@@ -237,6 +268,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
| Doc | Contents | | Doc | Contents |
|-----|----------| |-----|----------|
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP | | [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
| [docs/README.md](./docs/README.md) | Index of UI and product guides | | [docs/README.md](./docs/README.md) | Index of UI and product guides |
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development | | [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
+75
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "beenvoice", "name": "beenvoice",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@better-auth/expo": "^1.6.19", "@better-auth/expo": "^1.6.19",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
@@ -102,6 +103,60 @@
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1075.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-node": "^3.972.58", "@aws-sdk/middleware-flexible-checksums": "^3.974.33", "@aws-sdk/middleware-sdk-s3": "^3.972.54", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.31", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-login": "^3.972.55", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.58", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-ini": "^3.972.56", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/token-providers": "3.1074.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.33", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.8", "tslib": "^2.6.2" } }, "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.23", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.35", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1074.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.31", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@@ -488,6 +543,24 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@smithy/core": ["@smithy/core@3.26.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA=="],
"@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -766,6 +839,8 @@
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+74
View File
@@ -0,0 +1,74 @@
# Garage-only stack for Coolify when beenvoice runs as a separate Application resource.
#
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml
#
# ── Pair with a beenvoice Application (pick ONE) ───────────────────────────────
#
# A) Public Garage URL (most reliable — no shared Docker network required)
# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below).
# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com).
# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab.
# 4. beenvoice Application → S3_ENDPOINT=<that URL> → redeploy beenvoice.
#
# B) Internal Docker DNS (same Coolify destination network)
# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy.
# 2. beenvoice Application → same destination → enable "Connect to Predefined Network".
# 3. beenvoice → S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
#
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage).
# See docs/COOLIFY.md.
services:
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+124
View File
@@ -0,0 +1,124 @@
# beenvoice on Coolify — single Docker Compose resource (recommended).
#
# Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml
#
# 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik).
# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example).
# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network.
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
#
# Migrating from Application + separate Postgres + Garage compose:
# - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here.
# - Or keep external Postgres and remove the `db` service + volume from this file.
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
image: ${BEENVOICE_IMAGE:-beenvoice:coolify}
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-garage}
expose:
- "3000"
depends_on:
db:
condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+53
View File
@@ -17,5 +17,58 @@ services:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped restart: unless-stopped
# S3-compatible receipt storage for host dev (`bun dev`). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_dev_garage_meta:/var/lib/garage/meta
- beenvoice_dev_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes: volumes:
beenvoice_dev_pg_data: beenvoice_dev_pg_data:
beenvoice_dev_garage_meta:
beenvoice_dev_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+75 -2
View File
@@ -1,3 +1,13 @@
# Production stack (app + Postgres + Garage). Local dev Postgres/Garage: docker-compose.dev.yml
#
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
# or this file. S3_ENDPOINT=http://garage:3900 works only inside a single stack.
# Application + separate Garage → docs/COOLIFY.md.
#
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
# the existing local image and will NOT include new code. Use:
# ./scripts/docker-deploy.sh
# docker compose up -d --build
services: services:
app: app:
build: build:
@@ -5,7 +15,9 @@ services:
args: args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
image: beenvoice:local # Fixed default tag (beenvoice:local) is reused until you --build. docker-deploy.sh
# sets BEENVOICE_IMAGE=beenvoice:<git-sha> so each deploy gets a fresh tag.
image: ${BEENVOICE_IMAGE:-beenvoice:local}
environment: environment:
NODE_ENV: production NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
@@ -18,16 +30,24 @@ services:
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false} DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-} AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
S3_REGION: ${S3_REGION:-garage}
ports: ports:
- "${WEB_PORT:-${PORT:-3000}}:3000" - "${WEB_PORT:-${PORT:-3000}}:3000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
db: db:
@@ -46,5 +66,58 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# S3-compatible receipt storage (~50100 MB RAM vs MinIO). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes: volumes:
beenvoice_pg_data: beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+2
View File
@@ -190,6 +190,8 @@ App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run s
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars. Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
**Deploy / update:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code. The deploy script tags images as `beenvoice:<git-sha>`.
## Scripts ## Scripts
```bash ```bash
+137
View File
@@ -0,0 +1,137 @@
# Coolify deployment — beenvoice + Garage
beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. [Garage](https://garagehq.deuxfleurs.fr/) is the default on self-hosted Coolify (~50100 MB RAM vs MinIO's ~500 MB+).
## Why `getaddrinfo ENOTFOUND garage` happens
Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://garage:3900` work? |
|-------|--------------------------------|
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed |
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage.
---
## Quick fix — keep beenvoice as Application + separate Garage compose
Use this if you are **not** migrating to a single Compose stack today.
### Path A — public Garage URL (recommended, works without shared Docker network)
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource.
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`).
4. On the **beenvoice Application** → Environment:
```env
S3_ENDPOINT=https://s3.yourdomain.com
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<same as GARAGE_DEFAULT_ACCESS_KEY / S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<same as GARAGE_DEFAULT_SECRET_KEY / S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
5. **Redeploy beenvoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for Garage behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets.
### Path B — internal Docker DNS (same destination, no public Garage domain)
Use when you want S3 API traffic to stay on the Docker network.
1. Put beenvoice Application and Garage Compose in the **same Coolify project** and **same destination** (server/network).
2. **Garage Compose resource****Advanced** → enable **Connect to Predefined Network****redeploy Garage**.
3. **beenvoice Application****Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy beenvoice**.
4. Find the Garage resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the Garage container).
5. Set on beenvoice Application:
```env
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
```
Example: resource UUID `k8w2o0g4s0g8``S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A).
6. Match credentials and bucket:
```env
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
---
## Recommended long-term — one Compose stack
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc.
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
6. Redeploy.
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
### Migrating from Application + external Postgres + Garage (or legacy MinIO)
| Current | Action |
|---------|--------|
| beenvoice Application | Remove after Compose stack is live |
| Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file |
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
---
## Compose file reference
| File | Purpose |
|------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
---
## Checklist (Application + separate Garage)
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900`
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900`
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
- [ ] Redeployed beenvoice after env or network changes
## Verify from the beenvoice container
```bash
# Shell into beenvoice app container on the Coolify server
docker exec -it <beenvoice-container> sh
# Path A — public URL (403/404 on root is fine — confirms DNS + TLS)
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
# Path B — internal host from S3_ENDPOINT
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900"
```
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
+1
View File
@@ -8,6 +8,7 @@
|----------|-------------| |----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract | | [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
| [../README.md](../README.md) | Install, scripts, deployment | | [../README.md](../README.md) | Install, scripts, deployment |
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
## UI & product guides ## UI & product guides
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "beenvoice_audit_log" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"actorUserId" varchar(255) NOT NULL,
"action" varchar(100) NOT NULL,
"targetType" varchar(50) NOT NULL,
"targetId" varchar(255),
"metadata" jsonb,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_audit_log"
ADD CONSTRAINT "beenvoice_audit_log_actorUserId_beenvoice_user_id_fk"
FOREIGN KEY ("actorUserId") REFERENCES "public"."beenvoice_user"("id")
ON DELETE NO ACTION ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx" ON "beenvoice_audit_log" USING btree ("actorUserId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "beenvoice_audit_log" USING btree ("action");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx" ON "beenvoice_audit_log" USING btree ("createdAt");
@@ -0,0 +1,43 @@
CREATE INDEX IF NOT EXISTS "expense_business_id_idx" ON "beenvoice_expense" USING btree ("businessId");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_expense_receipt" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"expenseId" varchar(255) NOT NULL,
"storageKey" varchar(500) NOT NULL,
"originalFilename" varchar(255) NOT NULL,
"mimeType" varchar(100) NOT NULL,
"sizeBytes" integer NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_expense_receipt"
ADD CONSTRAINT "beenvoice_expense_receipt_expenseId_beenvoice_expense_id_fk"
FOREIGN KEY ("expenseId") REFERENCES "public"."beenvoice_expense"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "expense_receipt_expense_id_idx" ON "beenvoice_expense_receipt" USING btree ("expenseId");
--> statement-breakpoint
UPDATE "beenvoice_expense" e
SET "businessId" = i."businessId"
FROM "beenvoice_invoice" i
WHERE e."invoiceId" = i.id
AND e."businessId" IS NULL
AND i."businessId" IS NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_expense" e
SET "businessId" = sub.business_id
FROM (
SELECT
e2.id AS expense_id,
(
SELECT b2.id
FROM "beenvoice_business" b2
WHERE b2."createdById" = e2."createdById"
ORDER BY b2."isDefault" DESC, b2."createdAt" DESC
LIMIT 1
) AS business_id
FROM "beenvoice_expense" e2
WHERE e2."businessId" IS NULL
) sub
WHERE e.id = sub.expense_id
AND sub.business_id IS NOT NULL;
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
+28
View File
@@ -134,6 +134,34 @@
"when": 1781700000000, "when": 1781700000000,
"tag": "0018_user_onboarding", "tag": "0018_user_onboarding",
"breakpoints": true "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
} }
] ]
} }
+2
View File
@@ -15,6 +15,7 @@
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:deploy": "./scripts/docker-deploy.sh",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -26,6 +27,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@better-auth/expo": "^1.6.19", "@better-auth/expo": "^1.6.19",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
# Rebuilds the app image from the current working tree, then starts/restarts services
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}.
#
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
# changes from `git pull`. Always pass --build or use this script after pulling.
cd "$(dirname "$0")/.."
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if [[ -z "${BEENVOICE_IMAGE:-}" ]] && command -v git >/dev/null 2>&1; then
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
BEENVOICE_IMAGE="beenvoice:$(git rev-parse --short HEAD)"
export BEENVOICE_IMAGE
fi
fi
BEENVOICE_IMAGE="${BEENVOICE_IMAGE:-beenvoice:local}"
export BEENVOICE_IMAGE
echo "Deploying ${BEENVOICE_IMAGE} (docker compose up -d --build)..."
exec docker compose up -d --build "$@"
+22 -68
View File
@@ -2,12 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
import { Resend } from "resend"; import { sendPasswordResetForUser } from "~/lib/password-reset";
import { env } from "~/env"; import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import crypto from "crypto";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -17,22 +13,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Email is required" }, { status: 400 }); return NextResponse.json({ error: "Email is required" }, { status: 400 });
} }
// Validate email format const normalizedEmail = email.toLowerCase().trim();
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
windowMs: 60 * 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(normalizedEmail)) {
return NextResponse.json( return NextResponse.json(
{ error: "Invalid email format" }, { error: "Invalid email format" },
{ status: 400 }, { status: 400 },
); );
} }
// Check if user exists
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()), where: eq(users.email, normalizedEmail),
columns: { id: true },
}); });
// Always return success to prevent email enumeration attacks
// Don't reveal whether the user exists or not
if (!user) { if (!user) {
return NextResponse.json( return NextResponse.json(
{ {
@@ -44,62 +53,7 @@ export async function POST(request: NextRequest) {
); );
} }
// Generate reset token await sendPasswordResetForUser(user.id);
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Update user with reset token
await db
.update(users)
.set({
resetToken,
resetTokenExpiry,
})
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Send password reset email using Resend
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
console.log(`Password reset email sent to: ${email}`);
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
// Continue execution - don't fail the request if email fails
// This prevents revealing whether an account exists based on email delivery
}
return NextResponse.json( return NextResponse.json(
{ {
+17 -1
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors"; import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { resolveNewUserRole } from "~/lib/first-admin"; import { resolveNewUserRole } from "~/lib/first-admin";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
@@ -71,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
windowMs: 60 * 60 * 1000,
max: 5,
});
if (rateLimit) return rateLimit;
if (env.DISABLE_SIGNUPS === true) { if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json( return NextResponse.json(
{ error: "New account registration is currently disabled" }, { error: "New account registration is currently disabled" },
@@ -106,13 +113,22 @@ export async function POST(request: NextRequest) {
const { firstName, lastName, email, password } = parsed.data; const { firstName, lastName, email, password } = parsed.data;
const normalizedEmail = email.toLowerCase(); const normalizedEmail = email.toLowerCase();
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:register-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, normalizedEmail), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "Registration failed. Please check the form or sign in." },
{ status: 400 }, { status: 400 },
); );
} }
+23 -1
View File
@@ -1,11 +1,20 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { revokeUserSessions } from "~/lib/session-security";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
windowMs: 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const { token, password } = (await request.json()) as { const { token, password } = (await request.json()) as {
token: string; token: string;
password: string; password: string;
@@ -29,10 +38,21 @@ export async function POST(request: NextRequest) {
); );
} }
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired // Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and( where: and(
eq(users.resetToken, token), eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()), gt(users.resetTokenExpiry, new Date()),
), ),
}); });
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
} }
}); });
await revokeUserSessions(user.id);
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
+20 -1
View File
@@ -1,20 +1,39 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), {
windowMs: 60 * 1000,
max: 20,
});
if (ipRateLimit) return ipRateLimit;
const { token } = (await request.json()) as { token: string }; const { token } = (await request.json()) as { token: string };
if (!token || typeof token !== "string") { if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 }); return NextResponse.json({ error: "Token is required" }, { status: 400 });
} }
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:validate-reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired // Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and( where: and(
eq(users.resetToken, token), eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()), gt(users.resetTokenExpiry, new Date()),
), ),
}); });
+8 -1
View File
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization"); const authHeader = req.headers.get("authorization");
const secret = env.CRON_SECRET; const secret = env.CRON_SECRET;
if (secret && authHeader !== `Bearer ${secret}`) { if (!secret) {
return NextResponse.json(
{ error: "Cron secret is not configured" },
{ status: 500 },
);
}
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
+6
View File
@@ -42,6 +42,12 @@ export async function GET(
const pdfBlob = await generateInvoicePDFBlob(invoice, { const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined, pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor, pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText, pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
+39
View File
@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getOptionalServerSession } from "~/lib/auth-server";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { expenseReceipts } from "~/server/db/schema";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getOptionalServerSession(req.headers);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const receipt = await db.query.expenseReceipts.findFirst({
where: eq(expenseReceipts.id, id),
with: { expense: true },
});
if (receipt?.expense.createdById !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const body = await getObject(receipt.storageKey);
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": receipt.mimeType,
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
"Cache-Control": "private, max-age=3600",
},
});
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { env } from "~/env";
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form";
export default function RegisterPage() { export default function RegisterPage() {
return <RegisterForm />; return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
} }
+35 -2
View File
@@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Lock, Mail, User } from "lucide-react"; import { ArrowRight, Lock, Mail, User, UserX } from "lucide-react";
import { import {
AuthCard, AuthCard,
AuthCardHeader, AuthCardHeader,
@@ -22,7 +22,11 @@ function formatAuthError(message: string | undefined, fallback: string): string
return message; return message;
} }
export function RegisterForm() { interface RegisterFormProps {
signupsDisabled?: boolean;
}
export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) {
const router = useRouter(); const router = useRouter();
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
@@ -92,6 +96,35 @@ export function RegisterForm() {
} }
} }
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 ( return (
<AuthPageShell> <AuthPageShell>
<AuthCard> <AuthCard>
+12 -5
View File
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { env } from "~/env"; import { env } from "~/env";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { safeCallbackPath } from "~/lib/safe-callback-url";
import { toast } from "sonner"; import { toast } from "sonner";
interface SignInFormProps { interface SignInFormProps {
@@ -25,8 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -40,8 +40,15 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
setLoading(false); setLoading(false);
if (error) { if (error) {
const message = error.message?.toLowerCase() ?? "";
const rateLimited =
error.status === 429 ||
message.includes("too many") ||
message.includes("rate limit");
toast.error( toast.error(
error.message && error.message !== "Required" rateLimited
? "Too many sign-in attempts. Please wait a moment and try again."
: error.message && error.message !== "Required"
? error.message ? error.message
: "Invalid email or password", : "Invalid email or password",
); );
@@ -74,7 +81,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
description="Sign in to your workspace" description="Sign in to your workspace"
/> />
{signupDisabled && ( {!allowRegistration && (
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm"> <p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
New account registration is currently disabled. New account registration is currently disabled.
</p> </p>
@@ -155,7 +162,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
</Button> </Button>
</form> </form>
{allowRegistration && !signupDisabled && ( {allowRegistration && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
@@ -1,7 +1,39 @@
"use client"; "use client";
import { Shield } from "lucide-react"; import {
Activity,
Building2,
Clock,
FileText,
KeyRound,
Pencil,
ScrollText,
Search,
Shield,
Users,
} from "lucide-react";
import { useDeferredValue, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { EmptyState } from "~/components/layout/page-layout";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { dashboardStatGridClass } from "~/components/layout/dashboard-page";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -9,6 +41,15 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -18,74 +59,374 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
export function AdministrationContent() { const PAGE_SIZE = 25;
const {
data: accounts = [], const ACTION_LABELS: Record<string, string> = {
refetch, "user.profile_updated": "Profile updated",
error, "user.role_updated": "Role updated",
} = api.settings.listAccounts.useQuery(); "user.password_reset_sent": "Password reset sent",
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({ "platform.pdf_settings_updated": "PDF settings updated",
onSuccess: () => { };
toast.success("Account role updated");
void refetch(); function formatAction(action: string) {
}, return ACTION_LABELS[action] ?? action;
onError: (mutationError: { message: string }) => { }
toast.error(`Failed to update role: ${mutationError.message}`);
}, function AdminOverview() {
}); const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
if (error) { if (error) {
return ( return (
<Card className="bg-card border-border border"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center gap-2"> <CardTitle>Platform overview</CardTitle>
<CardDescription>Unable to load statistics.</CardDescription>
</CardHeader>
</Card>
);
}
const statCards = [
{
label: "Total users",
value: stats?.totalUsers ?? 0,
icon: Users,
},
{
label: `Active (${stats?.activeUserWindowDays ?? 30}d)`,
value: stats?.activeUsers ?? 0,
icon: Activity,
},
{
label: "Administrators",
value: stats?.adminCount ?? 0,
icon: Shield,
},
{
label: "Invoices",
value: stats?.totalInvoices ?? 0,
icon: FileText,
},
{
label: "Businesses",
value: stats?.totalBusinesses ?? 0,
icon: Building2,
},
{
label: "Clients",
value: stats?.totalClients ?? 0,
icon: Users,
},
{
label: "Time entries",
value: stats?.totalTimeEntries ?? 0,
icon: Clock,
},
];
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" /> <Shield className="text-primary h-5 w-5" />
Administration Platform overview
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Administrative access is required for this page. Aggregate counts only no customer data, credentials, or bulk PII.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent>
{isLoading ? (
<p className="text-muted-foreground text-sm">Loading statistics</p>
) : (
<div className={dashboardStatGridClass}>
{statCards.map((stat) => (
<Card key={stat.label}>
<CardContent className="p-4">
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
<stat.icon className="h-3.5 w-3.5" />
{stat.label}
</div>
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
type EditUserState = {
id: string;
name: string;
email: string;
role: "user" | "admin";
};
function AdminUsers() {
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const [offset, setOffset] = useState(0);
const [editUser, setEditUser] = useState<EditUserState | null>(null);
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetUserName, setResetUserName] = useState("");
const utils = api.useUtils();
const { data, isLoading, error, isFetching } = api.admin.listUsers.useQuery({
search: deferredSearch || undefined,
offset,
limit: PAGE_SIZE,
});
const updateUserMutation = api.admin.updateUser.useMutation({
onSuccess: () => {
toast.success("User updated");
setEditUser(null);
void utils.admin.listUsers.invalidate();
void utils.admin.listAuditLog.invalidate();
},
onError: (mutationError) => {
toast.error(mutationError.message);
},
});
const sendResetMutation = api.admin.sendPasswordReset.useMutation({
onSuccess: (result) => {
if (result.emailSent) {
toast.success("Password reset email sent");
} else {
toast.warning(
"Reset token created, but email could not be sent. Check Resend configuration.",
);
}
setResetUserId(null);
void utils.admin.listAuditLog.invalidate();
},
onError: (mutationError) => {
toast.error(mutationError.message);
},
});
const users = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>Administrative access is required.</CardDescription>
</CardHeader>
</Card> </Card>
); );
} }
return ( return (
<Card className="bg-card border-border border"> <>
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" /> <Users className="text-primary h-5 w-5" />
Accounts Users
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Manage account access and roles without opening customer data. Search accounts, edit profiles, and manage access.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-4">
{accounts.map((account) => ( <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 <div
key={account.id} key={user.id}
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between" className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
> >
<div className="min-w-0"> <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"> <p className="text-muted-foreground truncate text-xs">
{account.email} {user.email}
</p> </p>
<p className="text-muted-foreground mt-1 text-xs"> <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> </p>
</div> </div>
<Select <div className="flex flex-shrink-0 gap-2">
value={account.role} <Button
onValueChange={(role) => variant="outline"
updateAccountRoleMutation.mutate({ size="sm"
userId: account.id, onClick={() =>
role: role as "user" | "admin", 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -94,8 +435,187 @@ export function AdministrationContent() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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> </CardContent>
</Card> </Card>
); );
} }
export function AdministrationContent() {
return (
<PageTabs defaultValue="overview">
<PageTabsList>
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
<PageTabsTrigger value="users">Users</PageTabsTrigger>
<PageTabsTrigger value="audit">Audit log</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="overview">
<AdminOverview />
</PageTabsContent>
<PageTabsContent value="users">
<AdminUsers />
</PageTabsContent>
<PageTabsContent value="audit">
<AdminAuditLog />
</PageTabsContent>
</PageTabs>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default async function AdministrationPage() {
<DashboardPage> <DashboardPage>
<DashboardPageHeader <DashboardPageHeader
title="Administration" title="Administration"
description="Manage account access and platform administration" description="Platform statistics, user management, and audit logging"
/> />
<HydrateClient> <HydrateClient>
+448 -52
View File
@@ -1,9 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import {
DashboardPage,
dashboardStatGridClass,
} from "~/components/layout/dashboard-page";
import { EmptyState } from "~/components/layout/page-layout"; import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -28,10 +31,25 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker"; import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input"; 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 { 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 { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
interface ExpenseFormData { interface ExpenseFormData {
date: Date; date: Date;
@@ -44,6 +62,7 @@ interface ExpenseFormData {
taxDeductible: boolean; taxDeductible: boolean;
notes: string; notes: string;
clientId: string; clientId: string;
businessId: string;
} }
const defaultForm: ExpenseFormData = { const defaultForm: ExpenseFormData = {
@@ -57,24 +76,72 @@ const defaultForm: ExpenseFormData = {
taxDeductible: false, taxDeductible: false,
notes: "", notes: "",
clientId: "", 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() { export default function ExpensesPage() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<ExpenseDialogMode>("create");
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseFormData>(defaultForm); const [form, setForm] = useState<ExpenseFormData>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null); 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 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 { 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({ const create = api.expenses.create.useMutation({
onSuccess: () => { onSuccess: (expense) => {
toast.success("Expense added"); if (!expense) return;
toast.success("Expense saved — you can now attach receipts");
void utils.expenses.getAll.invalidate(); void utils.expenses.getAll.invalidate();
setOpen(false); setEditId(expense.id);
setForm(defaultForm); setDialogMode("edit");
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
@@ -84,6 +151,7 @@ export default function ExpensesPage() {
void utils.expenses.getAll.invalidate(); void utils.expenses.getAll.invalidate();
setOpen(false); setOpen(false);
setEditId(null); setEditId(null);
setDialogMode("create");
setForm(defaultForm); setForm(defaultForm);
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
@@ -97,25 +165,29 @@ export default function ExpensesPage() {
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const closeDialog = () => {
setOpen(false);
setEditId(null);
setDialogMode("create");
setForm(defaultForm);
};
const handleOpen = () => { const handleOpen = () => {
setEditId(null); 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); setOpen(true);
}; };
const handleEdit = (expense: (typeof expenses)[0]) => { const handleEdit = (expense: (typeof expenses)[0]) => {
setEditId(expense.id); setEditId(expense.id);
setForm({ setDialogMode("edit");
date: new Date(expense.date), setForm(expenseToForm(expense, defaultBusinessId));
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 ?? "",
});
setOpen(true); setOpen(true);
}; };
const handleSubmit = () => { const handleSubmit = () => {
@@ -130,6 +202,7 @@ export default function ExpensesPage() {
const payload = { const payload = {
...form, ...form,
clientId: form.clientId || undefined, clientId: form.clientId || undefined,
businessId: form.businessId || undefined,
category: form.category || undefined, category: form.category || undefined,
notes: form.notes || undefined, notes: form.notes || undefined,
taxDeductible: form.taxDeductible, taxDeductible: form.taxDeductible,
@@ -138,13 +211,63 @@ export default function ExpensesPage() {
else create.mutate(payload); 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 totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const visibleTotal = filteredExpenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses const billableTotal = expenses
.filter((e) => e.billable) .filter((e) => e.billable)
.reduce((s, e) => s + e.amount, 0); .reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses const deductibleTotal = expenses
.filter((e) => e.taxDeductible) .filter((e) => e.taxDeductible)
.reduce((s, e) => s + e.amount, 0); .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 ( return (
<DashboardPage> <DashboardPage>
@@ -195,19 +318,72 @@ export default function ExpensesPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Count With receipts
</p> </p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p> <p className="mt-1 text-2xl font-bold">{withReceipts}</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Expenses list */}
<Card> <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"> <CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" /> All Expenses <Receipt className="h-5 w-5" /> Expenses
</CardTitle> </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> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@@ -226,12 +402,48 @@ export default function ExpensesPage() {
</Button> </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"> <div className="divide-y">
{expenses.map((expense) => ( {filteredExpenses.map((expense) => (
<div <div
key={expense.id} 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="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -266,6 +478,7 @@ export default function ExpensesPage() {
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}).format(new Date(expense.date))} }).format(new Date(expense.date))}
{expense.business ? ` · ${expense.business.name}` : ""}
{expense.client ? ` · ${expense.client.name}` : ""} {expense.client ? ` · ${expense.client.name}` : ""}
</p> </p>
{expense.notes && ( {expense.notes && (
@@ -274,41 +487,162 @@ export default function ExpensesPage() {
</p> </p>
)} )}
</div> </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)} {formatCurrency(expense.amount, expense.currency)}
</p> </p>
<Button
variant="ghost" <div
size="sm" className="flex justify-end"
className="h-8 w-8 p-0" onClick={(e) => e.stopPropagation()}
onClick={() => handleEdit(expense)}
> >
<Pencil className="h-3.5 w-3.5" /> <DropdownMenu>
</Button> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => setDeleteId(expense.id)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="mr-2 h-4 w-4" />
</Button> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Add/Edit dialog */} <Dialog
<Dialog open={open} onOpenChange={setOpen}> open={open}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg"> 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> <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> </DialogHeader>
<div className="space-y-4 py-2"> <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"> <div className="space-y-2">
<Label>Description *</Label> <Label>Description *</Label>
<Input <Input
@@ -333,7 +667,9 @@ export default function ExpensesPage() {
<Label>Currency</Label> <Label>Currency</Label>
<Select <Select
value={form.currency} value={form.currency}
onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))} onValueChange={(v) =>
setForm((p) => ({ ...p, currency: v }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -364,7 +700,10 @@ export default function ExpensesPage() {
<Select <Select
value={form.category || "none"} value={form.category || "none"}
onValueChange={(v) => onValueChange={(v) =>
setForm((p) => ({ ...p, category: v === "none" ? "" : v })) setForm((p) => ({
...p,
category: v === "none" ? "" : v,
}))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -381,12 +720,40 @@ export default function ExpensesPage() {
</Select> </Select>
</div> </div>
</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"> <div className="space-y-2">
<Label>Client (optional)</Label> <Label>Client (optional)</Label>
<Select <Select
value={form.clientId || "none"} value={form.clientId || "none"}
onValueChange={(v) => onValueChange={(v) =>
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v })) setForm((p) => ({
...p,
clientId: v === "none" ? "" : v,
}))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -441,26 +808,55 @@ export default function ExpensesPage() {
placeholder="Additional details…" placeholder="Additional details…"
/> />
</div> </div>
</>
)}
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
</div> </div>
<DialogFooter> <DialogFooter className="gap-2 sm:gap-3">
<Button variant="outline" onClick={() => setOpen(false)}> {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 Cancel
</Button> </Button>
<Button <Button
className="w-full sm:w-auto"
onClick={handleSubmit} onClick={handleSubmit}
disabled={create.isPending || update.isPending} disabled={create.isPending || update.isPending}
> >
{create.isPending || update.isPending {create.isPending || update.isPending
? "Saving…" ? "Saving…"
: editId : isEditMode
? "Update" ? "Update"
: "Add Expense"} : "Save & add receipts"}
</Button> </Button>
</>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}> <Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -2,6 +2,10 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "~/components/data/data-table"; import { DataTable } from "~/components/data/data-table";
import {
formatLineItemDetail,
isFixedLineItem,
} from "~/lib/invoice-line-item";
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
<div className="sm:hidden"> <div className="sm:hidden">
<p className="font-medium">{item.description}</p> <p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @{" "} {formatDate(item.date)} &middot;{" "}
{formatCurrency(item.rate)}/hr {formatLineItemDetail(item.hours, item.rate, formatCurrency)}
</p> </p>
</div> </div>
</> </>
@@ -69,9 +73,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
{ {
accessorKey: "hours", accessorKey: "hours",
header: "Hours", header: "Hours",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="text-right">{row.getValue("hours")}</div> const hours = row.getValue<number>("hours");
), return (
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
);
},
meta: { meta: {
headerClassName: "hidden sm:table-cell", headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell",
@@ -80,9 +87,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
{ {
accessorKey: "rate", accessorKey: "rate",
header: "Rate", header: "Rate",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div> const item = row.original;
), return (
<div className="text-right">
{isFixedLineItem(item.hours)
? "—"
: `${formatCurrency(item.rate)}/hr`}
</div>
);
},
meta: { meta: {
headerClassName: "hidden sm:table-cell", headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell",
@@ -61,6 +61,8 @@ export function PDFDownloadButton({
await generateInvoicePDF(pdfData, { await generateInvoicePDF(pdfData, {
pdfTemplate: pdfSettings?.pdfTemplate, pdfTemplate: pdfSettings?.pdfTemplate,
pdfAccentColor: pdfSettings?.pdfAccentColor, pdfAccentColor: pdfSettings?.pdfAccentColor,
pdfFontFamily: pdfSettings?.pdfFontFamily,
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
pdfFooterText: pdfSettings?.pdfFooterText, pdfFooterText: pdfSettings?.pdfFooterText,
pdfShowLogo: pdfSettings?.pdfShowLogo, pdfShowLogo: pdfSettings?.pdfShowLogo,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers, pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
+3 -234
View File
@@ -1,236 +1,5 @@
import { import { redirect } from "next/navigation";
AlertCircle,
ArrowLeft,
CheckCircle,
Download,
FileSpreadsheet,
FileText,
Info,
Upload,
} from "lucide-react";
import Link from "next/link";
import { CSVImportPage } from "~/components/csv-import-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { HydrateClient } from "~/trpc/server";
// File Upload Instructions Component export default function ImportPage() {
function FormatInstructions() { redirect("/dashboard/settings?tab=data");
return (
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
{/* Required Format */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Required CSV Format
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/50 p-4">
<p className="text-muted-foreground font-mono text-sm">
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p>
</div>
<div className="space-y-3">
<h4 className="font-semibold">Required Columns:</h4>
<div className="grid gap-2">
{[
{ field: "DATE", desc: "Date of work (M/DD/YY format)" },
{ field: "DESCRIPTION", desc: "Description of work performed" },
{ field: "HOURS", desc: "Number of hours worked" },
{ field: "RATE", desc: "Hourly rate (decimal)" },
{
field: "AMOUNT",
desc: "Total amount (calculated from hours × rate)",
},
].map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="border text-xs">{col.field}</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
</span>
</div>
))}
</div>
</div>
<div className="pt-2">
<h4 className="mb-2 font-semibold">File Naming:</h4>
<p className="text-muted-foreground text-sm">
Name your CSV files in{" "}
<code className="bg-muted rounded px-1 text-xs">
YYYY-MM-DD.csv
</code>{" "}
format for automatic date detection.
</p>
</div>
</CardContent>
</Card>
{/* Sample Data & Download */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
Sample Template
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Download our sample CSV template to see the exact format required
for importing time entries.
</p>
<div className="bg-primary/10 p-4">
<div className="flex items-start gap-3">
<Info className="text-primary mt-0.5 h-5 w-5" />
<div>
<p className="text-success text-sm font-medium">Pro Tip</p>
<p className="text-success text-sm">
The template includes sample data and formatting examples to
help you get started quickly.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs break-all">
1/15/24,&quot;Web development work&quot;,8,75.00,600.00
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// Important Notes Section
function ImportantNotes() {
return (
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertCircle className="text-primary h-5 w-5" />
Important Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="mb-2 font-semibold">Before Importing:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Use M/DD/YY format for dates (e.g., 1/15/24)</li>
<li> Ensure rates are in decimal format (e.g., 75.50)</li>
<li> File names should follow YYYY-MM-DD.csv format</li>
<li> Select a client before importing</li>
</ul>
</div>
<div>
<h4 className="mb-2 font-semibold">What Happens:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Each CSV file creates one invoice</li>
<li> Invoice dates are derived from filename</li>
<li> Invoices are created in &quot;draft&quot; status</li>
<li> You can review and edit before sending</li>
</ul>
</div>
</div>
</CardContent>
</Card>
);
}
// File Format Help Section
function FileFormatHelp() {
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileSpreadsheet className="text-primary h-5 w-5" />
Supported File Formats
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2 text-center">
<div className="bg-accent mx-auto w-fit p-3">
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">CSV Files</h4>
<p className="text-muted-foreground text-sm">
Comma-separated values from Excel, Google Sheets, or any CSV
editor
</p>
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto w-fit p-3">
<Upload className="text-primary h-6 w-6" />
</div>
<h4 className="font-semibold">Max Size</h4>
<p className="text-muted-foreground text-sm">
Up to 10MB per file with no limit on number of rows
</p>
</div>
<div className="space-y-2 text-center">
<div className="bg-secondary mx-auto w-fit p-3">
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">Validation</h4>
<p className="text-muted-foreground text-sm">
Real-time validation with clear error messages and feedback
</p>
</div>
</div>
</CardContent>
</Card>
);
}
export default async function ImportPage() {
return (
<DashboardPage>
<DashboardPageHeader
title="Import Time Entries"
description="Upload CSV files to create invoices from your time tracking data"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
<ArrowLeft className="mr-2 h-5 w-5" />
Back to Invoices
</Button>
</Link>
</DashboardPageHeader>
<HydrateClient>
{/* Main CSV Import Component */}
<CSVImportPage />
{/* File Format Help */}
<FileFormatHelp />
{/* Format Instructions */}
<FormatInstructions />
{/* Important Notes */}
<ImportantNotes />
</HydrateClient>
</DashboardPage>
);
} }
+1 -7
View File
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page"; import { DashboardPage } from "~/components/layout/dashboard-page";
import { Plus, Upload } from "lucide-react"; import { Plus } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table"; import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
@@ -22,12 +22,6 @@ export default async function InvoicesPage() {
title="Invoices" title="Invoices"
description="Manage your invoices and track payments" description="Manage your invoices and track payments"
> >
<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="default" className="hover-lift shadow-md"> <Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new"> <Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-5 w-5" />
@@ -14,35 +14,57 @@ function stepIndex(step: OnboardingStepId) {
return ONBOARDING_STEPS.findIndex((item) => item.id === step); 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 }) { export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
const currentIndex = stepIndex(step); const currentIndex = stepIndex(step);
return ( return (
<nav aria-label="Setup progress" className="mb-8"> <nav aria-label="Setup progress" className="mb-8">
<ol className="mx-auto flex w-full max-w-md"> <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) => { {ONBOARDING_STEPS.map((item, index) => {
const isComplete = currentIndex > index; const isComplete = currentIndex > index;
const isCurrent = currentIndex === index; const isCurrent = currentIndex === index;
const isUpcoming = currentIndex < index; const isUpcoming = currentIndex < index;
const connectorComplete = currentIndex > index; const connectorComplete = currentIndex > index;
const circleCol = index * 2 + 1;
return ( return (
<li key={item.id} className="flex flex-1 flex-col items-center"> <div key={item.id} className="contents">
<div className="flex w-full items-center">
{index > 0 && ( {index > 0 && (
<div <div
className={cn( className={cn(
"h-0.5 flex-1 rounded-full transition-colors", "h-0.5 self-center rounded-full transition-colors",
connectorComplete || isCurrent connectorComplete ? "bg-primary" : "bg-border/80",
? "bg-primary"
: "bg-border/80",
)} )}
aria-hidden style={{ gridColumn: index * 2, gridRow: 1 }}
/> />
)} )}
<div <div
className={cn( className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors", "flex h-9 w-9 items-center justify-center justify-self-center rounded-full border-2 text-sm font-medium transition-colors",
isComplete && isComplete &&
"border-primary bg-primary text-primary-foreground", "border-primary bg-primary text-primary-foreground",
isCurrent && isCurrent &&
@@ -50,7 +72,7 @@ export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
isUpcoming && isUpcoming &&
"border-border/80 bg-background/60 text-muted-foreground", "border-border/80 bg-background/60 text-muted-foreground",
)} )}
aria-current={isCurrent ? "step" : undefined} style={{ gridColumn: circleCol, gridRow: 1 }}
> >
{isComplete ? ( {isComplete ? (
<Check className="h-4 w-4" aria-hidden /> <Check className="h-4 w-4" aria-hidden />
@@ -58,28 +80,20 @@ export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
<span>{index + 1}</span> <span>{index + 1}</span>
)} )}
</div> </div>
{index < ONBOARDING_STEPS.length - 1 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete ? "bg-primary" : "bg-border/80",
)}
aria-hidden
/>
)}
</div>
<span <span
className={cn( className={cn(
"mt-2 hidden text-xs font-medium sm:block", "hidden min-w-0 justify-self-center text-center text-xs leading-tight font-medium sm:block",
isCurrent ? "text-foreground" : "text-muted-foreground", isCurrent ? "text-foreground" : "text-muted-foreground",
)} )}
style={{ gridColumn: circleCol, gridRow: 2 }}
> >
{item.label} {item.label}
</span> </span>
</li> </div>
); );
})} })}
</ol> </div>
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden"> <p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "} Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
{ONBOARDING_STEPS.length} {ONBOARDING_STEPS.length}
+40 -14
View File
@@ -51,10 +51,14 @@ function toNumericChartValue(value: unknown) {
} }
export default function ReportsPage() { export default function ReportsPage() {
const [businessFilter, setBusinessFilter] = useState("all");
const { data: businesses = [] } = api.businesses.getAll.useQuery();
const { data: invoices = [], isLoading: invoicesLoading } = const { data: invoices = [], isLoading: invoicesLoading } =
api.invoices.getAll.useQuery(); api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } = const { data: expenses = [], isLoading: expensesLoading } =
api.expenses.getAll.useQuery(); api.expenses.getAll.useQuery(
businessFilter === "all" ? undefined : { businessId: businessFilter },
);
const { data: stats } = api.dashboard.getStats.useQuery(); const { data: stats } = api.dashboard.getStats.useQuery();
const isLoading = invoicesLoading || expensesLoading; const isLoading = invoicesLoading || expensesLoading;
@@ -62,9 +66,14 @@ export default function ReportsPage() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear)); const [taxYear, setTaxYear] = useState(String(currentYear));
const filteredInvoices = useMemo(() => {
if (businessFilter === "all") return invoices;
return invoices.filter((inv) => inv.businessId === businessFilter);
}, [invoices, businessFilter]);
// Overview data (last 12 months) // Overview data (last 12 months)
const overviewData = useMemo(() => { const overviewData = useMemo(() => {
if (!invoices.length) return null; if (!filteredInvoices.length) return null;
const now = new Date(); const now = new Date();
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
@@ -78,7 +87,7 @@ export default function ReportsPage() {
let totalPending = 0; let totalPending = 0;
let totalHours = 0; let totalHours = 0;
for (const inv of invoices) { for (const inv of filteredInvoices) {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -102,7 +111,7 @@ export default function ReportsPage() {
})); }));
const clientMap: Record<string, { name: string; revenue: number }> = {}; const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) { for (const inv of filteredInvoices) {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -126,7 +135,7 @@ export default function ReportsPage() {
paid: 0, paid: 0,
overdue: 0, overdue: 0,
}; };
for (const inv of invoices) { for (const inv of filteredInvoices) {
const s = getEffectiveInvoiceStatus( const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -142,13 +151,13 @@ export default function ReportsPage() {
totalHours, totalHours,
statusCount, statusCount,
}; };
}, [invoices]); }, [filteredInvoices]);
// Tax summary for selected year // Tax summary for selected year
const taxData = useMemo(() => { const taxData = useMemo(() => {
const year = parseInt(taxYear); const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => { const yearInvoices = filteredInvoices.filter((inv) => {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
@@ -224,20 +233,20 @@ export default function ReportsPage() {
yearInvoices, yearInvoices,
yearExpenses, yearExpenses,
}; };
}, [invoices, expenses, taxYear]); }, [filteredInvoices, expenses, taxYear]);
const availableYears = useMemo(() => { const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]); const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of invoices) for (const inv of filteredInvoices)
years.add(new Date(inv.issueDate).getFullYear()); years.add(new Date(inv.issueDate).getFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
return Array.from(years).sort((a, b) => b - a); return Array.from(years).sort((a, b) => b - a);
}, [invoices, expenses, currentYear]); }, [filteredInvoices, expenses, currentYear]);
const avgInvoice = const avgInvoice =
invoices.length > 0 filteredInvoices.length > 0
? (overviewData?.totalRevenue ?? 0) / ? (overviewData?.totalRevenue ?? 0) /
(invoices.filter( (filteredInvoices.filter(
(i) => (i) =>
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus, i.status as StoredInvoiceStatus,
@@ -335,6 +344,23 @@ export default function ReportsPage() {
description="Revenue and tax analytics" description="Revenue and tax analytics"
/> />
<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"> <PageTabs defaultValue="overview">
<PageTabsList> <PageTabsList>
<PageTabsTrigger value="overview" className="gap-1.5"> <PageTabsTrigger value="overview" className="gap-1.5">
@@ -573,7 +599,7 @@ export default function ReportsPage() {
<div <div
className="bg-primary h-full rounded-full" className="bg-primary h-full rounded-full"
style={{ style={{
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`, width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
}} }}
/> />
</div> </div>
@@ -584,7 +610,7 @@ export default function ReportsPage() {
</div> </div>
), ),
)} )}
{invoices.length === 0 && ( {filteredInvoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="text-muted-foreground py-6 text-center text-sm">
No invoices yet. No invoices yet.
</p> </p>
@@ -0,0 +1,245 @@
"use client";
import { CircleHelp, FileJson, FileSpreadsheet, FileText } from "lucide-react";
import { useState } from "react";
import {
ImportCsvTemplateButton,
ImportJsonTemplateButton,
} from "./import-sample-download";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { JSON_TEMPLATE } from "~/lib/invoice-import-templates";
const CSV_COLUMNS = [
{
field: "date",
required: false,
desc: "Work date (M/D/YY, YYYY-MM-DD, or ISO)",
},
{
field: "item",
required: false,
desc: "Short item name (combined with description if both present)",
},
{
field: "description",
required: "one of item/description",
desc: "Line item description",
},
{
field: "quantity",
required: true,
desc: "Hours or units (aliases: hours, qty)",
},
{
field: "rate",
required: true,
desc: "Unit rate (aliases: price, hourly rate)",
},
] as const;
export function ImportFormatInfoDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<CircleHelp className="mr-2 h-4 w-4" />
Format guide
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col sm:max-w-4xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Import format guide
</DialogTitle>
<DialogDescription>
CSV and JSON reference for bulk invoice imports. All imported
invoices are created as drafts for review.
</DialogDescription>
</DialogHeader>
<PageTabs defaultValue="csv" className="min-h-0 flex-1">
<PageTabsList>
<PageTabsTrigger value="csv">
<FileSpreadsheet className="mr-1.5 h-4 w-4" />
CSV
</PageTabsTrigger>
<PageTabsTrigger value="json">
<FileJson className="mr-1.5 h-4 w-4" />
JSON
</PageTabsTrigger>
</PageTabsList>
<PageTabsContent
value="csv"
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
>
<p className="text-muted-foreground text-sm">
One CSV file creates one invoice. The invoice title is the
filename without the extension (e.g.{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
acme-january.csv
</code>{" "}
title &quot;acme-january&quot;). Column headers are flexible
and auto-detected from the .csv extension.
</p>
<div className="bg-muted border-border rounded-md border p-3">
<p className="text-foreground font-mono text-sm">
date,description,quantity,rate
</p>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">
Columns (header row required)
</h4>
<div className="space-y-2">
{CSV_COLUMNS.map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="border font-mono text-xs">
{col.field}
</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
{col.required === true && " — required"}
{typeof col.required === "string" &&
`${col.required} required`}
</span>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Example rows</h4>
<div className="bg-muted border-border space-y-2 rounded-md border p-3">
<p className="text-foreground font-mono text-xs break-all">
2024-01-15,&quot;API development&quot;,8,125.00
</p>
<p className="text-foreground font-mono text-xs break-all">
1/16/24,Design review,2,125.00
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Rules</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Column names are case-insensitive. Legacy columns{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
HOURS
</code>{" "}
and{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
DATE
</code>{" "}
are still supported.
</li>
<li>
Select a default client in Settings Data before uploading
CSV files.
</li>
<li>
Each line item needs a description (or item), quantity, and
rate.
</li>
<li> Max 10 MB per file, up to 50 files at once.</li>
<li>
Preview staged invoices and fix per-row errors before you
commit the import.
</li>
</ul>
</div>
<ImportCsvTemplateButton />
</PageTabsContent>
<PageTabsContent
value="json"
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
>
<p className="text-muted-foreground text-sm">
Import one or many invoices from a single JSON file. Clients are
matched by email, then name, or created automatically when
details are provided.
</p>
<div className="space-y-2">
<h4 className="text-sm font-medium">Example</h4>
<div className="bg-muted border-border max-h-64 overflow-auto rounded-md border p-3">
<pre className="text-foreground font-mono text-xs whitespace-pre-wrap">
{JSON_TEMPLATE}
</pre>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Rules</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Root may be{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
{"{ invoices: [...] }"}
</code>
, an array, or a single invoice object.
</li>
<li>
Line items use{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
quantity
</code>{" "}
or{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
hours
</code>
.
</li>
<li>
Issue and due dates default from item dates (+30 days for
due).
</li>
<li>
New clients are created when JSON includes unknown client
details.
</li>
<li> Max 10 MB per file, up to 50 files at once.</li>
<li>
Partial success: valid invoices import; errors are reported
per row.
</li>
</ul>
</div>
<ImportJsonTemplateButton />
</PageTabsContent>
</PageTabs>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,7 @@
"use client";
import { ImportFormatInfoDialog } from "./import-format-info-dialog";
export function ImportPageHeaderActions() {
return <ImportFormatInfoDialog />;
}
@@ -0,0 +1,52 @@
"use client";
import { FileJson, FileSpreadsheet } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
downloadCsvTemplate,
downloadJsonTemplate,
} from "~/lib/invoice-import-templates";
import { cn } from "~/lib/utils";
export function ImportCsvTemplateButton({
className,
}: {
className?: string;
}) {
return (
<Button
variant="outline"
className={cn("hover-lift shadow-sm", className)}
onClick={downloadCsvTemplate}
>
<FileSpreadsheet className="mr-2 h-5 w-5" />
Download CSV template
</Button>
);
}
export function ImportJsonTemplateButton({
className,
}: {
className?: string;
}) {
return (
<Button
variant="outline"
className={cn("hover-lift shadow-sm", className)}
onClick={downloadJsonTemplate}
>
<FileJson className="mr-2 h-5 w-5" />
Download JSON template
</Button>
);
}
export function ImportTemplateButtons({ className }: { className?: string }) {
return (
<div className={className}>
<ImportCsvTemplateButton />
<ImportJsonTemplateButton />
</div>
);
}
@@ -21,6 +21,7 @@ import {
Link as LinkIcon, Link as LinkIcon,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { useAuthSession } from "~/hooks/use-auth-session"; import { useAuthSession } from "~/hooks/use-auth-session";
import * as React from "react"; import * as React from "react";
@@ -87,8 +88,24 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { useAppearance } from "~/components/providers/appearance-provider"; import { useAppearance } from "~/components/providers/appearance-provider";
import { brand, colorModes } from "~/lib/branding"; import { brand, colorModes } from "~/lib/branding";
import type { PdfTemplate } from "~/lib/appearance"; import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
import { ApiAccessSettings } from "./api-access-settings"; 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( const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame), () => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
@@ -106,9 +123,31 @@ function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value); 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 { data: session } = useAuthSession();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [nameInitialized, setNameInitialized] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
@@ -140,6 +179,8 @@ export function SettingsContent() {
const savePdfSettings = (patch: { const savePdfSettings = (patch: {
pdfTemplate?: PdfTemplate; pdfTemplate?: PdfTemplate;
pdfAccentColor?: string; pdfAccentColor?: string;
pdfFontFamily?: PdfFontFamily;
pdfNumericFontFamily?: PdfFontFamily;
pdfFooterText?: string; pdfFooterText?: string;
pdfShowLogo?: boolean; pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean; pdfShowPageNumbers?: boolean;
@@ -180,7 +221,7 @@ export function SettingsContent() {
}; };
// Queries // Queries
const { data: profile, refetch: refetchProfile } = const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
api.settings.getProfile.useQuery(); api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin"; const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery(); const { data: dataStats } = api.settings.getDataStats.useQuery();
@@ -368,16 +409,13 @@ export function SettingsContent() {
deleteDataMutation.mutate({ confirmText: deleteConfirmText }); deleteDataMutation.mutate({ confirmText: deleteConfirmText });
}; };
// Set initial name value when profile loads // Set initial name value once when profile loads
React.useEffect(() => { 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. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile.name); setName(profile?.name ?? session?.user?.name ?? "");
} setNameInitialized(true);
if (session?.user) { }, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
setName(session.user.name ?? "");
}
}, [session, profile?.name, name]);
// (Removed direct DOM mutation; provider handles applying preferences globally) // (Removed direct DOM mutation; provider handles applying preferences globally)
@@ -406,7 +444,7 @@ export function SettingsContent() {
]; ];
return ( return (
<PageTabs defaultValue="general"> <PageTabs value={activeTab} onValueChange={handleTabChange}>
<PageTabsList> <PageTabsList>
<PageTabsTrigger value="general">General</PageTabsTrigger> <PageTabsTrigger value="general">General</PageTabsTrigger>
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger> <PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
@@ -753,6 +791,73 @@ export function SettingsContent() {
className="mt-0" className="mt-0"
/> />
</div> </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>
<div className="space-y-2"> <div className="space-y-2">
@@ -810,6 +915,9 @@ export function SettingsContent() {
settings={{ settings={{
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic", pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827", pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
pdfFontFamily: pdfSettings?.pdfFontFamily ?? "sans",
pdfNumericFontFamily:
pdfSettings?.pdfNumericFontFamily ?? "mono",
pdfFooterText: pdfFooterText:
pdfSettings?.pdfFooterText ?? "Professional Invoicing", pdfSettings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true, pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
@@ -1139,6 +1247,27 @@ export function SettingsContent() {
</CardContent> </CardContent>
</Card> </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) */} {/* Delete Account (Danger Zone) */}
<Card className="bg-card border-destructive/50 border"> <Card className="bg-card border-destructive/50 border">
<CardHeader> <CardHeader>
+14 -2
View File
@@ -5,7 +5,19 @@ import { DashboardPage } from "~/components/layout/dashboard-page";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content"; import { SettingsContent } from "./_components/settings-content";
export default async function SettingsPage() { export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const params = await searchParams;
const validTabs = ["general", "preferences", "data", "api"] as const;
const initialTab = validTabs.includes(
params.tab as (typeof validTabs)[number],
)
? (params.tab as (typeof validTabs)[number])
: "general";
return ( return (
<DashboardPage> <DashboardPage>
<DashboardPageHeader <DashboardPageHeader
@@ -15,7 +27,7 @@ export default async function SettingsPage() {
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}> <Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent /> <SettingsContent initialTab={initialTab} />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</DashboardPage> </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>
);
}
+6 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { generateInvoicePDF } from "~/lib/pdf-export"; import { generateInvoicePDF } from "~/lib/pdf-export";
import { formatLineItemDetail } from "~/lib/invoice-line-item";
import { toast } from "sonner"; import { toast } from "sonner";
function formatDate(date: Date) { function formatDate(date: Date) {
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 break-words">{item.description}</p> <p className="font-medium text-gray-900 break-words">{item.description}</p>
<p className="text-gray-500"> <p className="text-gray-500">
{item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr {formatLineItemDetail(
item.hours,
item.rate,
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
)}
</p> </p>
</div> </div>
<p className="font-semibold text-gray-900 shrink-0"> <p className="font-semibold text-gray-900 shrink-0">
-31
View File
@@ -9,20 +9,6 @@ export const contentType = "image/png";
export default async function Image() { export default async function Image() {
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText); const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const geistMono = await fetch(
new URL(
"../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
import.meta.url,
),
).then((res) => res.arrayBuffer());
const playfair = await fetch(
new URL(
"../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
import.meta.url,
),
).then((res) => res.arrayBuffer());
return new ImageResponse( return new ImageResponse(
( (
<div <div
@@ -70,7 +56,6 @@ export default async function Image() {
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
fontFamily: "Geist Mono",
fontSize: 72, fontSize: 72,
fontWeight: 700, fontWeight: 700,
letterSpacing: "-0.02em", letterSpacing: "-0.02em",
@@ -84,7 +69,6 @@ export default async function Image() {
<div <div
style={{ style={{
marginTop: 32, marginTop: 32,
fontFamily: "Playfair Display",
fontSize: 40, fontSize: 40,
fontWeight: 600, fontWeight: 600,
color: "#09090b", color: "#09090b",
@@ -97,7 +81,6 @@ export default async function Image() {
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
fontFamily: "Geist Mono",
fontSize: 22, fontSize: 22,
fontWeight: 400, fontWeight: 400,
color: "#71717a", color: "#71717a",
@@ -113,20 +96,6 @@ export default async function Image() {
), ),
{ {
...size, ...size,
fonts: [
{
name: "Geist Mono",
data: geistMono,
style: "normal",
weight: 700,
},
{
name: "Playfair Display",
data: playfair,
style: "normal",
weight: 600,
},
],
}, },
); );
} }
+2 -886
View File
@@ -1,886 +1,2 @@
"use client"; /** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
import {
AlertCircle,
Clock,
DollarSign,
Eye,
FileText,
Trash2,
Upload,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { FileUpload } from "~/components/forms/file-upload";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
interface CSVRow {
DATE: string;
DESCRIPTION: string;
HOURS: number;
RATE: number;
AMOUNT: number;
}
interface ParsedItem {
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface FileData {
file: File;
parsedItems: ParsedItem[];
previewData: CSVRow[];
invoiceNumber: string;
clientId: string;
issueDate: Date | null;
dueDate: Date | null;
status: "pending" | "ready" | "error";
errors: string[];
hasDateError: boolean;
}
export function CSVImportPage() {
const [files, setFiles] = useState<FileData[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(
null,
);
const [isProcessing, setIsProcessing] = useState(false);
const [progressCount, setProgressCount] = useState(0);
// Fetch clients for dropdown
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
toast.success("Invoice created successfully");
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = "";
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote inside quoted field
current += '"';
i += 2; // Skip both quotes
} else {
// Toggle quote state
inQuotes = !inQuotes;
i++;
}
} else if (char === "," && !inQuotes) {
// End of field
result.push(current.trim());
current = "";
i++;
} else {
// Regular character
current += char;
i++;
}
}
// Add the last field
result.push(current.trim());
return result;
};
const parseCSV = (csvText: string): CSVRow[] => {
const lines = csvText.split("\n");
const headers = parseCSVLine(lines[0] ?? "");
// Validate headers
const requiredHeaders = ["DATE", "DESCRIPTION", "HOURS", "RATE", "AMOUNT"];
const missingHeaders = requiredHeaders.filter((h) => !headers?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required headers: ${missingHeaders.join(", ")}`);
}
return lines
.slice(1)
.filter((line) => line.trim())
.map((line) => {
const values = parseCSVLine(line);
return {
DATE: values[0] ?? "",
DESCRIPTION: values[1] ?? "",
HOURS: parseFloat(values[2] ?? "0") || 0,
RATE: parseFloat(values[3] ?? "0") || 0,
AMOUNT: parseFloat(values[4] ?? "0") || 0,
};
})
.filter((row) => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
};
const parseDate = (dateStr: string): Date => {
// Handle m/dd/yy format
const parts = dateStr.split("/");
if (parts.length === 3) {
const month = parseInt(parts[0] ?? "1") - 1; // 0-based month
const day = parseInt(parts[1] ?? "1");
const year = parseInt(parts[2] ?? "2000") + 2000; // Assume 20xx
return new Date(year, month, day);
}
// Fallback to standard date parsing
return new Date(dateStr);
};
const handleFileSelect = async (selectedFiles: File[]) => {
for (const file of selectedFiles) {
const errors: string[] = [];
let hasDateError = false;
let issueDate: Date | null = null;
let dueDate: Date | null = null;
// Check filename format
const filenameMatch = /^(\d{4}-\d{2}-\d{2})\.csv$/.exec(file.name);
if (!filenameMatch) {
errors.push("Filename must be in YYYY-MM-DD.csv format");
hasDateError = true;
} else {
const filenameDate = filenameMatch[1] ?? "";
issueDate = new Date(filenameDate);
if (isNaN(issueDate.getTime())) {
errors.push("Invalid date in filename");
hasDateError = true;
} else {
dueDate = new Date(issueDate);
dueDate.setDate(dueDate.getDate() + 30);
}
}
try {
const text = await file.text();
const csvData = parseCSV(text);
// Parse items for invoice creation
const items = csvData.map((row) => ({
date: parseDate(row.DATE),
description: row.DESCRIPTION,
hours: row.HOURS,
rate: row.RATE,
amount: row.HOURS * row.RATE, // Calculate amount ourselves
}));
const fileData: FileData = {
file,
parsedItems: items,
previewData: csvData,
invoiceNumber: issueDate
? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`
: `INV-${Date.now()}`,
clientId: globalClientId, // Use global client if set
issueDate,
dueDate,
status: errors.length > 0 ? "error" : "pending",
errors,
hasDateError,
};
setFiles((prev) => [...prev, fileData]);
if (errors.length > 0) {
toast.error(
`${file.name} has ${errors.length} error${errors.length > 1 ? "s" : ""}`,
);
} else {
toast.success(`Parsed ${items.length} items from ${file.name}`);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
const fileData: FileData = {
file,
parsedItems: [],
previewData: [],
invoiceNumber: `INV-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
clientId: globalClientId,
issueDate: null,
dueDate: null,
status: "error",
errors: [`Error parsing CSV: ${errorMessage}`],
hasDateError: true,
};
setFiles((prev) => [...prev, fileData]);
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
}
}
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
// Apply global client to all files that don't have a client selected
const applyGlobalClient = (clientId: string) => {
setFiles((prev) =>
prev.map((file) => ({
...file,
clientId: file.clientId || clientId, // Only apply if no client is already selected
})),
);
};
const updateFileData = (index: number, updates: Partial<FileData>) => {
setFiles((prev) =>
prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
// Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
) {
const errorIndex = newErrors.indexOf(
"Filename must be in YYYY-MM-DD.csv format",
);
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
// Remove invalid date error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Invalid date in filename")
) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(
(error) =>
error.includes("Filename") || error.includes("Invalid date"),
);
}
return updatedFile;
}),
);
};
const openPreview = (index: number) => {
setSelectedFileIndex(index);
setPreviewModalOpen(true);
};
const validateFiles = () => {
const errors: string[] = [];
files.forEach((fileData) => {
// Check for existing errors
if (fileData.errors.length > 0) {
errors.push(`${fileData.file.name}: ${fileData.errors.join(", ")}`);
}
if (!fileData.clientId && !globalClientId) {
errors.push(`${fileData.file.name}: Client not selected`);
}
if (fileData.parsedItems.length === 0) {
errors.push(`${fileData.file.name}: No valid items found`);
}
if (!fileData.issueDate) {
errors.push(`${fileData.file.name}: Issue date required`);
}
if (!fileData.dueDate) {
errors.push(`${fileData.file.name}: Due date required`);
}
});
return errors;
};
const processBatch = async () => {
const errors = validateFiles();
if (errors.length > 0) {
toast.error(`Please fix the following issues:\n${errors.join("\n")}`);
return;
}
setIsProcessing(true);
setProgressCount(0);
let successCount = 0;
let errorCount = 0;
for (const fileData of files) {
try {
// Validate required fields before sending
const clientId = fileData.clientId || globalClientId;
if (!clientId) {
throw new Error(`No client selected for ${fileData.file.name}`);
}
if (!fileData.issueDate) {
throw new Error(`No issue date for ${fileData.file.name}`);
}
if (!fileData.dueDate) {
throw new Error(`No due date for ${fileData.file.name}`);
}
if (!fileData.invoiceNumber) {
throw new Error(`No invoice number for ${fileData.file.name}`);
}
if (!fileData.parsedItems || fileData.parsedItems.length === 0) {
throw new Error(`No items found for ${fileData.file.name}`);
}
const invoiceData = {
invoiceNumber: fileData.invoiceNumber,
clientId: clientId,
issueDate: fileData.issueDate,
dueDate: fileData.dueDate,
status: "draft" as const,
notes: `Imported from CSV: ${fileData.file.name}`,
items: fileData.parsedItems.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
};
console.log("Creating invoice with data:", invoiceData);
await createInvoice.mutateAsync(invoiceData);
console.log("Invoice created successfully");
successCount++;
} catch (error) {
errorCount++;
console.error(
`Failed to create invoice for ${fileData.file.name}:`,
error,
);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
toast.error(
`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`,
);
}
setProgressCount((prev) => prev + 1);
}
setIsProcessing(false);
if (successCount > 0) {
toast.success(
`Successfully created ${successCount} invoice${successCount > 1 ? "s" : ""}`,
);
}
if (errorCount > 0) {
toast.error(
`Failed to create ${errorCount} invoice${errorCount > 1 ? "s" : ""}`,
);
}
if (successCount > 0) {
setFiles([]);
}
};
const totalFiles = files.length;
const readyFiles = files.filter(
(f) =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate,
).length;
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
const totalAmount = files.reduce(
(sum, f) =>
sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0),
0,
);
return (
<div className="space-y-6">
{/* Global Client Selection */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Default Client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium">
Select Default Client (Optional)
</Label>
<select
id="global-client"
value={globalClientId}
onChange={(e) => {
const newClientId = e.target.value;
setGlobalClientId(newClientId);
if (newClientId) {
applyGlobalClient(newClientId);
}
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
This client will be automatically selected for all uploaded files.
You can still change individual files below.
</p>
</div>
</CardContent>
</Card>
{/* File Upload Area */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Upload className="text-primary h-5 w-5" />
Upload CSV Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{ "text/csv": [".csv"] }}
maxFiles={50}
maxSize={5 * 1024 * 1024} // 5MB
placeholder="Drag & drop CSV files here, or click to select"
description="Files must be named YYYY-MM-DD.csv (e.g., 2024-01-15.csv). Up to 50 files can be uploaded at once."
/>
{/* Summary Card */}
{totalFiles > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Import Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalFiles}
</div>
<div className="text-muted-foreground text-sm">Files</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalItems}
</div>
<div className="text-muted-foreground text-sm">
Total Items
</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-muted-foreground text-sm">
Total Amount
</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{readyFiles}/{totalFiles}
</div>
<div className="text-muted-foreground text-sm">Ready</div>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
{/* File List */}
{files.length > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
Uploaded Files
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div key={index} className="border-border bg-card border p-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" />
<div>
<h3 className="text-foreground truncate font-medium">
{fileData.file.name}
</h3>
<p className="text-muted-foreground text-sm">
{fileData.parsedItems.length} items {" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
hours
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openPreview(index)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeFile(index)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Invoice Number
</Label>
<Input
value={fileData.invoiceNumber}
className="h-9 text-sm"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<select
value={fileData.clientId}
onChange={(e) =>
updateFileData(index, { clientId: e.target.value })
}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">Select Client</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Issue Date
</Label>
<DatePicker
date={fileData.issueDate ?? undefined}
onDateChange={(date) =>
updateFileData(index, { issueDate: date ?? null })
}
placeholder="Select issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Due Date
</Label>
<DatePicker
date={fileData.dueDate ?? undefined}
onDateChange={(date) =>
updateFileData(index, { dueDate: date ?? null })
}
placeholder="Select due date"
className="h-9"
/>
</div>
</div>
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
Issues Found
</span>
</div>
<ul className="text-destructive space-y-1 text-sm">
{fileData.errors.map((error, errorIndex) => (
<li
key={errorIndex}
className="flex items-start gap-2"
>
<span className="text-destructive"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-4 flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Total:{" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<Badge variant="destructive" className="text-xs">
{fileData.errors.length} Error
{fileData.errors.length !== 1 ? "s" : ""}
</Badge>
)}
<Badge
variant={
fileData.errors.length > 0
? "destructive"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "default"
: "secondary"
}
className="text-xs"
>
{fileData.errors.length > 0
? "Has Errors"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "Ready"
: "Pending"}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Batch Actions */}
{files.length > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<DollarSign className="text-primary h-5 w-5" />
Create Invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="flex w-full flex-col gap-2">
<span className="text-muted-foreground text-sm">
Creating invoices... ({progressCount}/{totalFiles})
</span>
<Progress
value={Math.round((progressCount / totalFiles) * 100)}
className="h-2"
/>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{readyFiles} of {totalFiles} files ready for import
</div>
<Button
onClick={processBatch}
disabled={readyFiles === 0 || isProcessing}
variant="default"
>
{isProcessing
? "Processing..."
: `Import ${readyFiles} Invoice${readyFiles !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" />
{selectedFileIndex !== null &&
files[selectedFileIndex]?.file.name}
</DialogTitle>
<DialogDescription className="text-gray-600">
Preview of parsed CSV data
</DialogDescription>
</DialogHeader>
{selectedFileIndex !== null && files[selectedFileIndex] && (
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<div className="grid flex-shrink-0 grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center gap-2">
<FileText className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm">
{files[selectedFileIndex].parsedItems.length} items
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
total hours
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm font-medium">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="p-0">
<div className="max-h-96 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-muted-foreground p-2 text-left font-medium">
Date
</th>
<th className="text-muted-foreground p-2 text-left font-medium">
Description
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Hours
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Rate
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Amount
</th>
</tr>
</thead>
<tbody>
{files[selectedFileIndex].parsedItems.map(
(item, index) => (
<tr key={index} className="border-border border-b">
<td className="text-foreground p-2 whitespace-nowrap">
{item.date.toLocaleDateString()}
</td>
<td className="text-foreground max-w-xs truncate p-2">
{item.description}
</td>
<td className="text-foreground p-2 text-right whitespace-nowrap">
{item.hours}
</td>
<td className="text-foreground p-2 text-right whitespace-nowrap">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="text-foreground p-2 text-right font-medium whitespace-nowrap">
{item.amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
<Button
variant="outline"
onClick={() => setPreviewModalOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { FileText, Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import { isImageReceipt, receiptUrl } from "~/components/expenses/receipt-utils";
import { cn } from "~/lib/utils";
interface ReceiptPreview {
id: string;
mimeType: string;
originalFilename: string;
}
interface ExpenseReceiptIndicatorProps {
expenseId: string;
receiptCount: number;
receiptPreview: ReceiptPreview | null;
className?: string;
}
export function ExpenseReceiptIndicator({
expenseId,
receiptCount,
receiptPreview,
className,
}: ExpenseReceiptIndicatorProps) {
const [listOpen, setListOpen] = useState(false);
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId },
{ enabled: listOpen && receiptCount > 1 },
);
if (receiptCount === 0) {
return (
<span className={cn("text-muted-foreground text-xs", className)}></span>
);
}
const handleClick = () => {
if (receiptCount === 1 && receiptPreview) {
setViewerReceipt({
id: receiptPreview.id,
originalFilename: receiptPreview.originalFilename,
mimeType: receiptPreview.mimeType,
});
return;
}
setListOpen(true);
};
const previewIsImage =
receiptPreview && isImageReceipt(receiptPreview.mimeType);
return (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClick}
className={cn(
"hover:bg-muted h-auto gap-2 px-2 py-1.5 font-normal",
className,
)}
title={
receiptCount === 1
? "View receipt"
: `View ${receiptCount} receipts`
}
>
{previewIsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={receiptUrl(receiptPreview.id)}
alt=""
className="h-8 w-8 rounded object-cover ring-1 ring-black/5"
/>
) : (
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded ring-1 ring-black/5">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
)}
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Paperclip className="h-3 w-3" />
{receiptCount}
</span>
</Button>
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
<Dialog open={listOpen} onOpenChange={setListOpen}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>Receipts ({receiptCount})</DialogTitle>
</DialogHeader>
<div className="space-y-2">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : (
receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={(r) => {
setListOpen(false);
setViewerReceipt(r);
}}
compact
/>
))
)}
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { ExternalLink, Eye, FileText, Loader2, Trash2 } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import {
formatReceiptSize,
isImageReceipt,
receiptUrl,
} from "~/components/expenses/receipt-utils";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
export interface ExpenseReceiptRecord {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}
interface ExpenseReceiptItemProps {
receipt: ExpenseReceiptRecord;
expenseId: string;
onView: (receipt: ReceiptViewerTarget) => void;
compact?: boolean;
readOnly?: boolean;
}
export function ExpenseReceiptItem({
receipt,
expenseId,
onView,
compact = false,
readOnly = false,
}: ExpenseReceiptItemProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = api.useUtils();
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
onSuccess: () => {
toast.success("Receipt removed");
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
setConfirmDelete(false);
},
onError: (e) => toast.error(e.message),
});
const url = receiptUrl(receipt.id);
const isImage = isImageReceipt(receipt.mimeType);
return (
<>
<div
className={
compact
? "flex items-center gap-2 rounded-md border p-2"
: "flex items-center gap-3 rounded-md border p-2 sm:p-3"
}
>
<button
type="button"
onClick={() => onView(receipt)}
className="hover:ring-primary/40 focus-visible:ring-ring shrink-0 overflow-hidden rounded transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
aria-label={`View ${receipt.originalFilename}`}
>
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
className={
compact ? "h-10 w-10 object-cover" : "h-12 w-12 object-cover sm:h-14 sm:w-14"
}
/>
) : (
<div
className={
compact
? "bg-muted flex h-10 w-10 items-center justify-center"
: "bg-muted flex h-12 w-12 items-center justify-center sm:h-14 sm:w-14"
}
>
<FileText className="text-muted-foreground h-5 w-5 sm:h-6 sm:w-6" />
</div>
)}
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{receipt.originalFilename}
</p>
<p className="text-muted-foreground text-xs">
{formatReceiptSize(receipt.sizeBytes)}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onView(receipt)}
title="View receipt"
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" asChild>
<a href={url} target="_blank" rel="noreferrer" title="Open in new tab">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
{!readOnly && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-8 w-8 p-0"
onClick={() => setConfirmDelete(true)}
disabled={deleteReceipt.isPending}
title="Delete receipt"
>
{deleteReceipt.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete receipt?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{receipt.originalFilename}&rdquo; will be permanently
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteReceipt.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteReceipt.isPending}
onClick={(e) => {
e.preventDefault();
deleteReceipt.mutate({ id: receipt.id });
}}
>
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useRef, useState } from "react";
import { Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Label } from "~/components/ui/label";
import { FileUpload } from "~/components/forms/file-upload";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import {
fileToBase64,
RECEIPT_ACCEPT,
RECEIPT_MAX_SIZE,
RECEIPT_UPLOAD_HINT,
} from "~/components/expenses/receipt-utils";
interface ExpenseReceiptsPanelProps {
expenseId: string | null;
readOnly?: boolean;
}
export function ExpenseReceiptsPanel({
expenseId,
readOnly = false,
}: ExpenseReceiptsPanelProps) {
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const [uploadKey, setUploadKey] = useState(0);
const processedFileCountRef = useRef(0);
const utils = api.useUtils();
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId: expenseId! },
{ enabled: !!expenseId },
);
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => {
if (expenseId) {
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
}
},
onError: (e) => toast.error(e.message),
});
const handleFiles = async (files: File[]) => {
if (!expenseId || files.length === 0) return;
const newFiles = files.slice(processedFileCountRef.current);
processedFileCountRef.current = files.length;
if (newFiles.length === 0) return;
let uploaded = 0;
for (const file of newFiles) {
try {
const data = await fileToBase64(file);
await uploadReceipt.mutateAsync({
expenseId,
filename: file.name,
mimeType: file.type || "application/octet-stream",
data,
});
uploaded++;
} catch (error) {
const message =
error instanceof Error ? error.message : "Upload failed";
toast.error(`${file.name}: ${message}`);
}
}
if (uploaded > 0) {
toast.success(
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
);
processedFileCountRef.current = 0;
setUploadKey((k) => k + 1);
}
};
if (!expenseId) {
return (
<div className="space-y-2 border-t pt-4">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
</Label>
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
Save the expense first, then drag and drop receipts here.
</div>
</div>
);
}
return (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
{receipts.length > 0 && (
<span className="text-muted-foreground text-xs font-normal">
({receipts.length})
</span>
)}
</Label>
{uploadReceipt.isPending && (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading
</span>
)}
</div>
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : receipts.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
{readOnly
? "No receipts attached."
: "No receipts yet. Drop images or PDFs below."}
</div>
) : (
<div className="space-y-2">
{receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={setViewerReceipt}
readOnly={readOnly}
/>
))}
</div>
)}
{!readOnly && (
<FileUpload
key={`${expenseId}-${uploadKey}`}
onFilesSelected={(files) => void handleFiles(files)}
accept={RECEIPT_ACCEPT}
maxFiles={5}
maxSize={RECEIPT_MAX_SIZE}
disabled={uploadReceipt.isPending}
placeholder="Drop receipts here or tap to browse"
description={RECEIPT_UPLOAD_HINT}
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
/>
)}
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
export const RECEIPT_ACCEPT: Record<string, string[]> = {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
"application/pdf": [".pdf"],
};
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
export const RECEIPT_UPLOAD_HINT =
"PNG, JPG, or PDF · up to 10MB each";
export function receiptUrl(receiptId: string) {
return `/api/receipts/${receiptId}`;
}
export function isImageReceipt(mimeType: string) {
return mimeType.startsWith("image/");
}
export function isPdfReceipt(mimeType: string) {
return mimeType === "application/pdf";
}
export function formatReceiptSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export async function fileToBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
if (!base64) {
reject(new Error("Failed to read file"));
return;
}
resolve(base64);
};
reader.onerror = () =>
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}
@@ -0,0 +1,92 @@
"use client";
import { ExternalLink, FileText } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
isImageReceipt,
isPdfReceipt,
receiptUrl,
} from "~/components/expenses/receipt-utils";
export interface ReceiptViewerTarget {
id: string;
originalFilename: string;
mimeType: string;
}
interface ReceiptViewerDialogProps {
receipt: ReceiptViewerTarget | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ReceiptViewerDialog({
receipt,
open,
onOpenChange,
}: ReceiptViewerDialogProps) {
if (!receipt) return null;
const url = receiptUrl(receipt.id);
const isImage = isImageReceipt(receipt.mimeType);
const isPdf = isPdfReceipt(receipt.mimeType);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
<DialogHeader className="shrink-0">
<DialogTitle className="truncate pr-8">
{receipt.originalFilename}
</DialogTitle>
</DialogHeader>
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={receipt.originalFilename}
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
/>
) : isPdf ? (
<iframe
src={url}
title={receipt.originalFilename}
className="h-[min(70vh,720px)] w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
<FileText className="h-10 w-10" />
<p>Preview not available for this file type.</p>
<Button variant="outline" size="sm" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open file
</a>
</Button>
</div>
)}
</div>
<DialogFooter className="shrink-0 sm:justify-between">
<Button variant="outline" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open in new tab
</a>
</Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+25 -5
View File
@@ -108,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch business data if editing // Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } = const { data: business, isLoading: isLoadingBusiness } =
api.businesses.getById.useQuery( api.businesses.getById.useQuery(
{ id: businessId! }, { id: businessId! },
{ enabled: mode === "edit" && !!businessId }, {
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
); );
// Fetch email configuration if editing // Fetch email configuration if editing
const { data: emailConfig, isLoading: isLoadingEmailConfig } = const { data: emailConfig, isLoading: isLoadingEmailConfig } =
api.businesses.getEmailConfig.useQuery( api.businesses.getEmailConfig.useQuery(
{ id: businessId! }, { id: businessId! },
{ enabled: mode === "edit" && !!businessId }, {
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
); );
// Update email configuration mutation // Update email configuration mutation
@@ -142,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}, },
}); });
// Load business data when editing
useEffect(() => { useEffect(() => {
if (business && mode === "edit") { // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [businessId]);
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
@@ -164,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
resendDomain: emailConfig?.resendDomain ?? "", resendDomain: emailConfig?.resendDomain ?? "",
emailFromName: emailConfig?.emailFromName ?? "", emailFromName: emailConfig?.emailFromName ?? "",
}); });
setInitialized(true);
} }
}, [business, emailConfig, mode]); }, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
const handleInputChange = (field: string, value: string | boolean) => { const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
+16 -4
View File
@@ -90,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch client data if editing // Fetch client data if editing
const { data: client, isLoading: isLoadingClient } = const { data: client, isLoading: isLoadingClient } =
api.clients.getById.useQuery( api.clients.getById.useQuery(
{ id: clientId! }, { id: clientId! },
{ enabled: mode === "edit" && !!clientId }, {
enabled: mode === "edit" && !!clientId,
refetchOnWindowFocus: false,
},
); );
const createClient = api.clients.create.useMutation({ const createClient = api.clients.create.useMutation({
@@ -118,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
}, },
}); });
// Load client data when editing
useEffect(() => { useEffect(() => {
if (client && mode === "edit") { // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [clientId]);
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (client && mode === "edit" && !initialized) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({ setFormData({
name: client.name, name: client.name,
@@ -135,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
defaultHourlyRate: client.defaultHourlyRate ?? null, defaultHourlyRate: client.defaultHourlyRate ?? null,
currency: client.currency ?? "USD", currency: client.currency ?? "USD",
}); });
setInitialized(true);
} }
}, [client, mode]); }, [client, mode, initialized]);
const handleInputChange = (field: string, value: string | number | null) => { const handleInputChange = (field: string, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
+3 -2
View File
@@ -2,6 +2,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface EmailPreviewProps { interface EmailPreviewProps {
subject: string; subject: string;
@@ -54,7 +55,7 @@ export function EmailPreview({
const calculateTotal = () => { const calculateTotal = () => {
if (!invoice?.items) return 0; if (!invoice?.items) return 0;
const subtotal = invoice.items.reduce( const subtotal = invoice.items.reduce(
(sum, item) => sum + item.hours * item.rate, (sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0, 0,
); );
const taxAmount = subtotal * (invoice.taxRate / 100); const taxAmount = subtotal * (invoice.taxRate / 100);
@@ -83,7 +84,7 @@ export function EmailPreview({
description: item.description ?? "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount ?? item.hours * item.rate, amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
+1 -1
View File
@@ -152,7 +152,7 @@ export function FileUpload({
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors", "cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
"hover:border-primary/40 hover:bg-primary/10", "hover:border-primary/40 hover:bg-primary/10",
isDragActive && "border-primary/40 bg-primary/10", isDragActive && "border-primary/40 bg-primary/10",
isDragReject && "border-destructive/40 bg-destructive/10", isDragReject && "border-destructive/40 bg-destructive/10",
@@ -33,6 +33,7 @@ import {
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
Total Total
</span> </span>
<span className="text-primary text-lg font-bold"> <span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)} ${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
+35 -9
View File
@@ -60,6 +60,11 @@ import {
import { STATUS_OPTIONS } from "./invoice/types"; import { STATUS_OPTIONS } from "./invoice/types";
import type { InvoiceFormData, InvoiceItem } from "./invoice/types"; import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
import type { ParsedLineItem } from "~/lib/parse-line-item"; import type { ParsedLineItem } from "~/lib/parse-line-item";
import {
applyBillingTypeChange,
calculateLineItemAmount,
getLineItemBillingType,
} from "~/lib/invoice-line-item";
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel"; import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
import { CountUp } from "~/components/ui/count-up"; import { CountUp } from "~/components/ui/count-up";
@@ -123,6 +128,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
hours: 1, hours: 1,
rate: 0, rate: 0,
amount: 0, amount: 0,
billingType: "hourly",
}, },
], ],
}; };
@@ -180,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount, amount: item.amount,
billingType: getLineItemBillingType(item.hours),
})) || []; })) || [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
setFormData({ setFormData({
@@ -206,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1, hours: 1,
rate: 0, rate: 0,
amount: 0, amount: 0,
billingType: "hourly",
}, },
], ],
}); });
@@ -238,7 +246,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const totals = React.useMemo(() => { const totals = React.useMemo(() => {
const subtotal = formData.items.reduce( const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate, (sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0, 0,
); );
const taxAmount = (subtotal * formData.taxRate) / 100; const taxAmount = (subtotal * formData.taxRate) / 100;
@@ -268,6 +276,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description || "Service", description: item.description || "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})), })),
}), }),
[formData], [formData],
@@ -298,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1, hours: 1,
rate: prev.defaultHourlyRate ?? 0, rate: prev.defaultHourlyRate ?? 0,
amount: prev.defaultHourlyRate ?? 0, amount: prev.defaultHourlyRate ?? 0,
billingType: "hourly",
}, },
], ],
})); }));
@@ -313,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: parsed.description, description: parsed.description,
hours: parsed.hours ?? 1, hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0, rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0), amount: calculateLineItemAmount(
parsed.hours ?? 1,
parsed.rate ?? prev.defaultHourlyRate ?? 0,
),
billingType: "hourly",
}, },
], ],
})); }));
@@ -333,14 +347,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
items: prev.items.map((item, i) => { items: prev.items.map((item, i) => {
if (i === idx) { if (i !== idx) return item;
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
const next = applyBillingTypeChange(value, item);
return {
...item,
...next,
billingType: value,
};
}
const updated = { ...item, [field]: value }; const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") { 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 updated;
}
return item;
}), }),
})); }));
}; };
@@ -435,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: i.description, description: i.description,
hours: i.hours, hours: i.hours,
rate: i.rate, rate: i.rate,
amount: i.hours * i.rate, amount: calculateLineItemAmount(i.hours, i.rate),
})), })),
}; };
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined) if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
@@ -771,7 +794,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<span className="text-muted-foreground">Hours</span> <span className="text-muted-foreground">Hours</span>
<span className="font-mono text-xl font-semibold"> <span className="font-mono text-xl font-semibold">
<CountUp <CountUp
value={formData.items.reduce((s, i) => s + i.hours, 0)} value={formData.items.reduce(
(s, i) => s + (i.hours > 0 ? i.hours : 0),
0,
)}
suffix="h" suffix="h"
/> />
</span> </span>
@@ -898,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: calculateLineItemAmount(item.hours, item.rate),
})), })),
}} }}
/> />
+68 -4
View File
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import {
calculateLineItemAmount,
getLineItemBillingType,
type LineItemBillingType,
} from "~/lib/invoice-line-item";
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item"; import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
import { import {
useLineItemSuggestions, useLineItemSuggestions,
type LineItemSuggestion, type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions"; } from "~/hooks/use-line-item-suggestions";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
@@ -23,6 +35,7 @@ interface InvoiceItem {
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
billingType?: LineItemBillingType;
} }
interface InvoiceLineItemsProps { interface InvoiceLineItemsProps {
@@ -149,13 +162,21 @@ function DescriptionAutocomplete({
); );
} }
const LINE_ITEM_GRID =
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>( const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => { ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid", "group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
LINE_ITEM_GRID,
)} )}
> >
<DatePicker <DatePicker
@@ -177,6 +198,25 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
disabled={readOnly} 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 <NumberInput
value={item.hours} value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)} onChange={(value) => onUpdate(index, "hours", value)}
@@ -187,6 +227,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
suffix="h" suffix="h"
disabled={readOnly} disabled={readOnly}
/> />
)}
<NumberInput <NumberInput
value={item.rate} value={item.rate}
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
/> />
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums"> <div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)} ${lineTotal.toFixed(2)}
</div> </div>
{!readOnly ? ( {!readOnly ? (
@@ -235,6 +276,10 @@ function MobileLineItem({
onDescriptionChange, onDescriptionChange,
readOnly, readOnly,
}: LineItemRowProps) { }: LineItemRowProps) {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return ( return (
<div <div
id={`invoice-item-${index}-mobile`} id={`invoice-item-${index}-mobile`}
@@ -264,6 +309,22 @@ function MobileLineItem({
inputClassName="h-8 px-2 text-xs" inputClassName="h-8 px-2 text-xs"
disabled={readOnly} 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 <NumberInput
value={item.hours} value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)} onChange={(value) => onUpdate(index, "hours", value)}
@@ -274,6 +335,7 @@ function MobileLineItem({
suffix="h" suffix="h"
disabled={readOnly} disabled={readOnly}
/> />
) : null}
<NumberInput <NumberInput
value={item.rate} value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)} onChange={(value) => onUpdate(index, "rate", value)}
@@ -285,7 +347,7 @@ function MobileLineItem({
disabled={readOnly} disabled={readOnly}
/> />
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums"> <span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)} ${lineTotal.toFixed(2)}
</span> </span>
{!readOnly ? ( {!readOnly ? (
<Button <Button
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
onUpdateItem(index, "description", s.description); onUpdateItem(index, "description", s.description);
onUpdateItem(index, "hours", s.hours); onUpdateItem(index, "hours", s.hours);
onUpdateItem(index, "rate", s.rate); onUpdateItem(index, "rate", s.rate);
onUpdateItem(index, "billingType", "hourly");
setSuggestions([]); setSuggestions([]);
setQueriedIndex(null); setQueriedIndex(null);
} }
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
) : null} ) : null}
<AnimatePresence> <AnimatePresence>
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border"> <div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid"> <div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
<span>Date</span> <span>Date</span>
<span>Description</span> <span>Description</span>
<span className="text-center">Type</span>
<span className="text-center">Hours</span> <span className="text-center">Hours</span>
<span className="text-center">Rate</span> <span className="text-center">Rate</span>
<span className="text-right">Amount</span> <span className="text-right">Amount</span>
+3
View File
@@ -3,6 +3,8 @@ import { type RouterOutputs } from "~/trpc/react";
export type ClientType = RouterOutputs["clients"]["getAll"][number]; export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number]; export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
import type { LineItemBillingType } from "~/lib/invoice-line-item";
export interface InvoiceItem { export interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
@@ -10,6 +12,7 @@ export interface InvoiceItem {
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
billingType: LineItemBillingType;
} }
export interface InvoiceFormData { export interface InvoiceFormData {
+680
View File
@@ -0,0 +1,680 @@
"use client";
import {
AlertCircle,
Building2,
DollarSign,
Eye,
FileJson,
FileSpreadsheet,
FileText,
Trash2,
Upload,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { FileUpload } from "~/components/forms/file-upload";
import {
dashboardGapClass,
dashboardGridClass,
dashboardStatGridClass,
} from "~/components/layout/dashboard-page";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
detectImportFormat,
parseInvoiceCSV,
parseInvoiceJSON,
type ImportFormat,
type ImportInvoice,
} from "~/lib/invoice-import";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
interface StagedInvoice extends ImportInvoice {
id: string;
clientId: string;
format: ImportFormat;
}
const NONE = "__none__";
function newId() {
return crypto.randomUUID();
}
export function InvoiceImportPage() {
const [invoices, setInvoices] = useState<StagedInvoice[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [globalBusinessId, setGlobalBusinessId] = useState("");
const [previewId, setPreviewId] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
const utils = api.useUtils();
const bulkImport = api.invoices.bulkImport.useMutation({
onSuccess: (result) => {
void utils.invoices.getAll.invalidate();
if (result.clientsCreated > 0) {
void utils.clients.getAll.invalidate();
}
const parts = [
`${result.invoicesCreated} invoice${result.invoicesCreated !== 1 ? "s" : ""} created`,
];
if (result.clientsCreated > 0) {
parts.push(
`${result.clientsCreated} client${result.clientsCreated !== 1 ? "s" : ""} created`,
);
}
toast.success(parts.join(", "));
if (result.errors.length > 0) {
toast.warning(
`${result.errors.length} invoice${result.errors.length !== 1 ? "s" : ""} skipped:\n${result.errors.slice(0, 3).join("\n")}${result.errors.length > 3 ? "\n..." : ""}`,
);
}
setInvoices([]);
},
onError: (error) => {
toast.error(error.message || "Import failed");
},
});
const applyGlobalClient = (clientId: string) => {
setInvoices((prev) =>
prev.map((inv) => ({
...inv,
clientId: inv.clientId || clientId,
})),
);
};
const handleFileSelect = async (selectedFiles: File[]) => {
for (const file of selectedFiles) {
const format = detectImportFormat(file.name);
const text = await file.text();
if (format === "json") {
const parsed = parseInvoiceJSON(text);
const staged: StagedInvoice[] = parsed.map((inv) => ({
...inv,
id: newId(),
clientId: globalClientId,
format: "json" as const,
sourceFile: file.name,
}));
setInvoices((prev) => [...prev, ...staged]);
const errorCount = staged.filter((s) => s.errors.length > 0).length;
if (errorCount > 0) {
toast.error(
`${file.name}: ${errorCount} invoice${errorCount !== 1 ? "s" : ""} with validation issues`,
);
} else {
toast.success(
`Parsed ${staged.length} invoice${staged.length !== 1 ? "s" : ""} from ${file.name}`,
);
}
} else {
const parsed = parseInvoiceCSV(text, file.name);
const staged: StagedInvoice = {
...parsed,
id: newId(),
clientId: globalClientId,
format: "csv",
};
setInvoices((prev) => [...prev, staged]);
if (parsed.errors.length > 0) {
toast.error(
`${file.name}: ${parsed.errors.length} issue${parsed.errors.length !== 1 ? "s" : ""}`,
);
} else {
toast.success(
`Parsed ${parsed.items.length} items from ${file.name}`,
);
}
}
}
};
const removeInvoice = (id: string) => {
setInvoices((prev) => prev.filter((inv) => inv.id !== id));
};
const updateInvoice = (id: string, updates: Partial<StagedInvoice>) => {
setInvoices((prev) =>
prev.map((inv) => {
if (inv.id !== id) return inv;
const updated = { ...inv, ...updates };
if (updates.issueDate !== undefined && !updates.dueDate) {
const due = new Date(updated.issueDate ?? new Date());
due.setDate(due.getDate() + 30);
updated.dueDate = due;
}
return updated;
}),
);
};
const isReady = (inv: StagedInvoice) =>
inv.errors.length === 0 &&
inv.items.length > 0 &&
!!(inv.clientId || globalClientId || inv.client?.name) &&
!!inv.issueDate &&
!!inv.dueDate;
const readyCount = invoices.filter(isReady).length;
const validateBeforeImport = (): string[] => {
const errors: string[] = [];
if (!globalBusinessId && (!businesses || businesses.length === 0)) {
errors.push("Create a business in Settings before importing");
}
invoices.forEach((inv) => {
if (inv.errors.length > 0) {
errors.push(`${inv.name}: ${inv.errors.join("; ")}`);
}
if (inv.items.length === 0) {
errors.push(`${inv.name}: no line items`);
}
if (!inv.clientId && !globalClientId && !inv.client?.name) {
errors.push(`${inv.name}: client required`);
}
if (!inv.issueDate) errors.push(`${inv.name}: issue date required`);
if (!inv.dueDate) errors.push(`${inv.name}: due date required`);
});
return errors;
};
const processImport = async () => {
const errors = validateBeforeImport();
if (errors.length > 0) {
toast.error(`Fix these issues first:\n${errors.slice(0, 5).join("\n")}`);
return;
}
const readyInvoices = invoices.filter(isReady);
if (readyInvoices.length === 0) return;
setIsProcessing(true);
try {
await bulkImport.mutateAsync({
defaultClientId: globalClientId || undefined,
defaultBusinessId: globalBusinessId || undefined,
invoices: readyInvoices.map((inv) => ({
name: inv.name,
issueDate: inv.issueDate,
dueDate: inv.dueDate,
clientId: inv.clientId || globalClientId || undefined,
client: inv.client,
items: inv.items.map((item) => ({
date: item.date,
description: item.description,
quantity: item.quantity,
rate: item.rate,
})),
sourceFile: inv.sourceFile,
})),
});
} finally {
setIsProcessing(false);
}
};
const previewInvoice = previewId
? invoices.find((i) => i.id === previewId)
: null;
const totalItems = invoices.reduce((sum, inv) => sum + inv.items.length, 0);
const totalAmount = invoices.reduce(
(sum, inv) =>
sum + inv.items.reduce((s, item) => s + item.quantity * item.rate, 0),
0,
);
return (
<div className={cn("flex flex-col", dashboardGapClass)}>
{/* Upload — primary action */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="text-primary h-5 w-5" />
Upload files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{
"text/csv": [".csv"],
"application/json": [".json"],
}}
maxFiles={50}
maxSize={10 * 1024 * 1024}
placeholder="Drag & drop CSV or JSON files here, or click to select"
description="CSV: one file = one invoice. JSON: multiple invoices per file."
/>
{invoices.length > 0 && (
<div className={cn("bg-primary/10 p-4", dashboardStatGridClass)}>
<SummaryStat label="Invoices" value={invoices.length} />
<SummaryStat label="Line items" value={totalItems} />
<SummaryStat
label="Total amount"
value={totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
/>
<SummaryStat
label="Ready"
value={`${readyCount}/${invoices.length}`}
/>
</div>
)}
</CardContent>
</Card>
{/* Defaults */}
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="text-primary h-5 w-5" />
Default business
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-business" className="text-sm font-medium">
Business for imported invoices
</Label>
<Select
value={globalBusinessId || NONE}
onValueChange={(value) =>
setGlobalBusinessId(value === NONE ? "" : value)
}
disabled={loadingBusinesses}
>
<SelectTrigger id="global-business" className="h-11">
<SelectValue placeholder="Use default business" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Use default business</SelectItem>
{businesses?.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
{b.isDefault ? " (default)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Required your default business is used if none is selected.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Default client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium">
Client for CSV imports (optional)
</Label>
<Select
value={globalClientId || NONE}
onValueChange={(value) => {
const id = value === NONE ? "" : value;
setGlobalClientId(id);
if (id) applyGlobalClient(id);
}}
disabled={loadingClients}
>
<SelectTrigger id="global-client" className="h-11">
<SelectValue placeholder="No default client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
No default (JSON client or per-invoice)
</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
CSV files need a client. JSON can include client details per
invoice.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Staged invoices */}
{invoices.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoices.map((inv) => (
<div
key={inv.id}
className="border-border bg-muted/20 space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{inv.format === "json" ? (
<FileJson className="text-primary h-5 w-5 shrink-0" />
) : (
<FileSpreadsheet className="text-primary h-5 w-5 shrink-0" />
)}
<div className="min-w-0">
<h3 className="text-foreground truncate font-medium">
{inv.name}
</h3>
<p className="text-muted-foreground text-sm">
{inv.items.length} items
{inv.sourceFile ? `${inv.sourceFile}` : ""}
{inv.client?.name ? `${inv.client.name}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPreviewId(inv.id)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeInvoice(inv.id)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Invoice title
</Label>
<Input
value={inv.name}
className="h-9 text-sm"
onChange={(e) =>
updateInvoice(inv.id, { name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<Select
value={inv.clientId || NONE}
onValueChange={(value) =>
updateInvoice(inv.id, {
clientId: value === NONE ? "" : value,
})
}
disabled={loadingClients}
>
<SelectTrigger className="h-9">
<SelectValue
placeholder={
inv.client?.name
? `Use JSON: ${inv.client.name}`
: "Select client"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
{inv.client?.name
? `Use JSON: ${inv.client.name}`
: "Select client"}
</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Issue date
</Label>
<DatePicker
date={inv.issueDate}
onDateChange={(date) =>
updateInvoice(inv.id, { issueDate: date })
}
placeholder="Issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Due date
</Label>
<DatePicker
date={inv.dueDate}
onDateChange={(date) =>
updateInvoice(inv.id, { dueDate: date })
}
placeholder="Due date"
className="h-9"
/>
</div>
</div>
{inv.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
Issues
</span>
</div>
<ul className="text-destructive space-y-1 text-sm">
{inv.errors.map((err, i) => (
<li key={i}> {err}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Total:{" "}
{inv.items
.reduce((s, item) => s + item.quantity * item.rate, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
<Badge variant={isReady(inv) ? "default" : "secondary"}>
{isReady(inv) ? "Ready" : "Pending"}
</Badge>
</div>
</div>
))}
</CardContent>
</Card>
)}
{invoices.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="text-primary h-5 w-5" />
Import invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="flex w-full flex-col gap-2">
<span className="text-muted-foreground text-sm">
Importing {readyCount} invoice
{readyCount !== 1 ? "s" : ""}...
</span>
<Progress value={50} className="h-2" />
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-muted-foreground text-sm">
{readyCount} of {invoices.length} ready all imported as
drafts
</span>
<Button
onClick={processImport}
disabled={readyCount === 0 || isProcessing}
className="sm:shrink-0"
>
{isProcessing
? "Importing..."
: `Import ${readyCount} Invoice${readyCount !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={!!previewId} onOpenChange={() => setPreviewId(null)}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
{previewInvoice?.name}
</DialogTitle>
<DialogDescription>Line item preview</DialogDescription>
</DialogHeader>
{previewInvoice && (
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
Date
</th>
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
Description
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Qty
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Rate
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Amount
</th>
</tr>
</thead>
<tbody>
{previewInvoice.items.map((item, idx) => (
<tr key={idx} className="border-border border-b">
<td className="p-2 text-sm whitespace-nowrap">
{item.date?.toLocaleDateString() ?? "—"}
</td>
<td className="max-w-xs truncate p-2 text-sm">
{item.description}
</td>
<td className="p-2 text-right text-sm">{item.quantity}</td>
<td className="p-2 text-right text-sm">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="p-2 text-right text-sm font-medium">
{(item.quantity * item.rate).toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewId(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function SummaryStat({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="text-center">
<div className="text-primary text-2xl font-bold">{value}</div>
<div className="text-muted-foreground text-sm">{label}</div>
</div>
);
}
@@ -30,6 +30,8 @@ const SPECIAL_SEGMENTS: Record<string, string> = {
import: "Import", import: "Import",
export: "Export", export: "Export",
dashboard: "Dashboard", dashboard: "Dashboard",
entries: "All entries",
"time-clock": "Time clock",
}; };
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@@ -53,7 +53,7 @@ export function AppearanceProviderSynced({
if (!serverColorMode?.colorMode) return; if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return; if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode); setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true; serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]); }, [serverColorMode?.colorMode]);
+208 -76
View File
@@ -35,37 +35,76 @@ import {
resolveEffectiveHourlyRate, resolveEffectiveHourlyRate,
startedAtFromMinutesAgo, startedAtFromMinutesAgo,
} from "~/lib/time-clock"; } from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
const FEATURED_CLIENT_COUNT = 4; const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago"; type StartMode = "now" | "pick" | "ago";
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
function RunningTextFields({
running,
updateRunningPending,
onDescriptionCommit,
onStartedAtCommit,
}: {
running: { id: string; description: string | null; startedAt: Date };
updateRunningPending: boolean;
onDescriptionCommit: (description: string) => void;
onStartedAtCommit: (startedAt: Date) => void;
}) {
const [title, setTitle] = useState(running.description ?? "");
const [runningStartedAt, setRunningStartedAt] = useState(() =>
toDatetimeLocalValue(running.startedAt),
);
return (
<>
<div className="space-y-2">
<Label htmlFor="clock-running-title">What are you working on?</Label>
<Input
id="clock-running-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => onDescriptionCommit(title)}
placeholder="What are you working on?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="clock-running-start">Started at</Label>
<Input
id="clock-running-start"
type="datetime-local"
value={runningStartedAt}
onChange={(e) => {
const value = e.target.value;
setRunningStartedAt(value);
if (!value) return;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return;
onStartedAtCommit(parsed);
}}
disabled={updateRunningPending}
/>
</div>
</>
);
}
export type TimeClockPanelProps = { export type TimeClockPanelProps = {
defaultClientId?: string; defaultClientId?: string;
defaultInvoiceId?: string; defaultInvoiceId?: string;
compact?: boolean; compact?: boolean;
}; };
function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
function ClientChip({ function ClientChip({
label, label,
active, active,
@@ -127,6 +166,7 @@ export function TimeClockPanel({
const [startMode, setStartMode] = useState<StartMode>("now"); const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState(""); const [pickedStart, setPickedStart] = useState("");
const [minutesAgo, setMinutesAgo] = useState("30"); const [minutesAgo, setMinutesAgo] = useState("30");
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId; const draftClientId = running ? (running.clientId ?? "") : clientId;
@@ -145,6 +185,10 @@ export function TimeClockPanel({
const last = getLastTimeClockClientId(); const last = getLastTimeClockClientId();
if (last) ids.push(last); if (last) ids.push(last);
if (running?.clientId && !ids.includes(running.clientId)) {
ids.unshift(running.clientId);
}
for (const entry of todayEntries ?? []) { for (const entry of todayEntries ?? []) {
if (entry.clientId && !ids.includes(entry.clientId)) { if (entry.clientId && !ids.includes(entry.clientId)) {
ids.push(entry.clientId); ids.push(entry.clientId);
@@ -157,7 +201,7 @@ export function TimeClockPanel({
} }
return ids; return ids;
}, [clients, todayEntries]); }, [clients, todayEntries, running]);
const visibleClients = useMemo(() => { const visibleClients = useMemo(() => {
if (!clients?.length) return []; if (!clients?.length) return [];
@@ -191,6 +235,26 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: () => {
void utils.timeEntries.getRunning.invalidate();
void utils.invoices.getBillable.invalidate();
},
onError: (e) => toast.error(e.message),
});
function handleRunningDescriptionCommit(nextTitle: string) {
if (!running) return;
const next = resolveClockDescription(nextTitle);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtCommit(parsed: Date) {
if (!running) return;
updateRunning.mutate({ startedAt: parsed });
}
const clockOut = api.timeEntries.clockOut.useMutation({ const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
const message = describeClockOutOutcome({ const message = describeClockOutOutcome({
@@ -227,6 +291,11 @@ export function TimeClockPanel({
}); });
function handleClientChange(value: string) { function handleClientChange(value: string) {
if (running) {
updateRunning.mutate({ clientId: value, invoiceId: "" });
return;
}
setClientId(value); setClientId(value);
setInvoiceId(""); setInvoiceId("");
setLastTimeClockClientId(value); setLastTimeClockClientId(value);
@@ -234,6 +303,15 @@ export function TimeClockPanel({
setRate(client?.defaultHourlyRate ?? 0); setRate(client?.defaultHourlyRate ?? 0);
} }
function handleInvoiceChange(value: string) {
const next = value === "__none__" ? "" : value;
if (running) {
updateRunning.mutate({ invoiceId: next });
return;
}
setInvoiceId(next);
}
function resolveStartedAt(): Date | undefined { function resolveStartedAt(): Date | undefined {
if (startMode === "now") return undefined; if (startMode === "now") return undefined;
if (startMode === "pick") { if (startMode === "pick") {
@@ -293,6 +371,8 @@ export function TimeClockPanel({
const displayRate = running ? (running.rate ?? 0) : rate; const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle = formatRunningTimerLabel(running?.description); const runningTitle = formatRunningTimerLabel(running?.description);
const activeClientId = running ? (running.clientId ?? "") : clientId;
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
return ( return (
<div className={compact ? "space-y-4" : "space-y-6"}> <div className={compact ? "space-y-4" : "space-y-6"}>
@@ -347,7 +427,7 @@ export function TimeClockPanel({
<ClientChip <ClientChip
key={client.id} key={client.id}
label={client.name} label={client.name}
active={clientId === client.id} active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)} onClick={() => handleClientChange(client.id)}
/> />
))} ))}
@@ -383,7 +463,7 @@ export function TimeClockPanel({
<Label>Invoice</Label> <Label>Invoice</Label>
<Select <Select
value={invoiceId || "__none__"} value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)} onValueChange={handleInvoiceChange}
disabled={!clientId} disabled={!clientId}
> >
<SelectTrigger> <SelectTrigger>
@@ -485,6 +565,85 @@ export function TimeClockPanel({
</Collapsible> </Collapsible>
</> </>
) : ( ) : (
<>
<RunningTextFields
key={running.id}
running={running}
updateRunningPending={updateRunning.isPending}
onDescriptionCommit={handleRunningDescriptionCommit}
onStartedAtCommit={handleRunningStartedAtCommit}
/>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
{visibleClients.map((client) => (
<ClientChip
key={client.id}
label={client.name}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
{!showAllClients && hiddenClientCount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setShowAllClients(true)}
>
+{hiddenClientCount} more
</Button>
) : null}
</div>
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
<Select
value={activeClientId || undefined}
onValueChange={handleClientChange}
disabled={updateRunning.isPending}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{clients?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={activeInvoiceId || "__none__"}
onValueChange={handleInvoiceChange}
disabled={!activeClientId || updateRunning.isPending}
>
<SelectTrigger>
<SelectValue
placeholder={
activeClientId
? "Draft invoice (optional)"
: "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label> <Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input <Input
@@ -498,6 +657,7 @@ export function TimeClockPanel({
} }
/> />
</div> </div>
</>
)} )}
{running ? ( {running ? (
@@ -529,70 +689,42 @@ export function TimeClockPanel({
</CardContent> </CardContent>
</Card> </Card>
{!compact && todayEntries && todayEntries.length > 0 ? ( {!compact ? (
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Today&apos;s entries</CardTitle> <CardTitle className="text-base">Today&apos;s entries</CardTitle>
<Button variant="ghost" size="sm" className="h-8" asChild>
<Link href="/dashboard/time-clock/entries">View all entries</Link>
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{todayEntries {todayEntries?.some((e) => e.endedAt) ? (
.filter((e) => e.endedAt) <TimeEntryList
.map((entry, index, entries) => { entries={todayEntries}
const href = entryHref(entry); onEdit={(entry) => setEditEntryId(entry.id)}
const isLast = index === entries.length - 1; />
const rowClassName = cn( ) : (
"flex items-start justify-between gap-4 py-3", <p className="text-muted-foreground py-4 text-center text-sm">
!isLast && "border-border border-b", No entries today.{" "}
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">
{formatRunningTimerLabel(entry.description)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</>
);
if (href) {
return (
<Link <Link
key={entry.id} href="/dashboard/time-clock/entries"
href={href} className="text-primary hover:underline"
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
> >
{content} View history
</Link> </Link>
); </p>
} )}
return (
<div key={entry.id} className={rowClassName}>
{content}
</div>
);
})}
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</div> </div>
); );
} }
@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt),
[entries],
);
const grouped = useMemo(
() => groupEntriesByDate(completedEntries),
[completedEntries],
);
if (isLoading) {
return (
<Card>
<CardContent className="text-muted-foreground p-6 text-sm">
Loading entries
</CardContent>
</Card>
);
}
if (completedEntries.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Clock className="h-6 w-6" />}
title="No time entries yet"
description="Start the timer to track billable hours. Completed entries will show up here."
action={
<Button asChild>
<Link href="/dashboard/time-clock">
<Play className="mr-2 h-4 w-4" />
Start timer
</Link>
</Button>
}
className="py-16"
/>
</CardContent>
</Card>
);
}
return (
<>
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
/>
))}
</CardContent>
</Card>
))}
</div>
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</>
);
}
@@ -0,0 +1,274 @@
"use client";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { invoiceLabel } from "~/lib/time-entry-display";
import type { RouterOutputs } from "~/trpc/react";
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
export type TimeEntryEditDialogProps = {
entryId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
type TimeEntryEditFormProps = {
entry: TimeEntry;
entryId: string;
clients: RouterOutputs["clients"]["getAll"];
onClose: () => void;
};
function TimeEntryEditForm({
entry,
entryId,
clients,
onClose,
}: TimeEntryEditFormProps) {
const utils = api.useUtils();
const [description, setDescription] = useState(entry.description ?? "");
const [clientId, setClientId] = useState(entry.clientId ?? "");
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
const [rate, setRate] = useState(entry.rate ?? 0);
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
const [endedAt, setEndedAt] = useState(() =>
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
);
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: Boolean(clientId) },
);
const hoursPreview = useMemo(() => {
if (!startedAt || !endedAt) return null;
const start = new Date(startedAt);
const end = new Date(endedAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
toast.success("Time entry updated");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
toast.success("Time entry deleted");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
function handleSave() {
const start = new Date(startedAt);
const end = endedAt ? new Date(endedAt) : undefined;
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
toast.error("Invalid start or end time");
return;
}
if (end && end <= start) {
toast.error("End time must be after start time");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate,
startedAt: start,
endedAt: end,
hours: hoursPreview ?? undefined,
});
}
return (
<>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="entry-description">Description</Label>
<Input
id="entry-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Client</Label>
<Select
value={clientId || "__none__"}
onValueChange={(v) => {
setClientId(v === "__none__" ? "" : v);
setInvoiceId("");
}}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
disabled={!clientId}
>
<SelectTrigger>
<SelectValue placeholder="Not on invoice" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Not on invoice</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Hourly rate</Label>
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="entry-start">Started</Label>
<Input
id="entry-start"
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="entry-end">Ended</Label>
<Input
id="entry-end"
type="datetime-local"
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
/>
</div>
</div>
{hoursPreview != null ? (
<p className="text-muted-foreground text-sm">
Duration: {hoursPreview.toFixed(2)}h
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
</p>
) : null}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
type="button"
variant="destructive"
disabled={deleteEntry.isPending}
onClick={() => deleteEntry.mutate({ id: entryId })}
>
Delete
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
Save
</Button>
</div>
</DialogFooter>
</>
);
}
export function TimeEntryEditDialog({
entryId,
open,
onOpenChange,
}: TimeEntryEditDialogProps) {
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: Boolean(entryId) && open },
);
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit time entry</DialogTitle>
</DialogHeader>
{entryQuery.isLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : entryQuery.data && entryId ? (
<TimeEntryEditForm
key={entryQuery.data.id}
entry={entryQuery.data}
entryId={entryId}
clients={clients}
onClose={() => onOpenChange(false)}
/>
) : (
<p className="text-muted-foreground text-sm">Time entry not found.</p>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,100 @@
import Link from "next/link";
import { cn } from "~/lib/utils";
import { formatRunningTimerLabel } from "~/lib/time-clock";
import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntryRow({
entry,
isLast,
onEdit,
}: {
entry: TimeEntryListItem;
isLast?: boolean;
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const href = onEdit ? null : entryHref(entry);
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">{formatRunningTimerLabel(entry.description)}</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${invoiceLabel(entry.invoice)}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? <p className="text-muted-foreground">${entry.rate}/hr</p> : null}
</div>
</>
);
if (href) {
return (
<Link
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</Link>
);
}
if (onEdit) {
return (
<button
type="button"
onClick={() => onEdit(entry)}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</button>
);
}
return (
<div className={rowClassName}>
{content}
</div>
);
}
export function TimeEntryList({
entries,
onEdit,
}: {
entries: TimeEntryListItem[];
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null;
return (
<>
{completed.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === completed.length - 1}
onEdit={onEdit}
/>
))}
</>
);
}
+2 -2
View File
@@ -68,7 +68,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-0 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
@@ -212,7 +212,7 @@ function SelectContentWithSearch({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border-0 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
+18 -2
View File
@@ -33,8 +33,18 @@ export const env = createEnv({
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
DB_DISABLE_SSL: optionalEnvBoolean(), DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean(), DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
CRON_SECRET: z.string().optional(), // Optional — only gates POST /api/cron/generate-recurring; the route itself
// returns a clean error when unset, so deployments that don't use recurring
// invoices don't need to configure it.
CRON_SECRET: z.string().min(32).optional(),
// S3-compatible object storage (optional — local .data/receipts/ fallback when unset)
S3_ENDPOINT: z.string().url().optional(),
S3_BUCKET: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_FORCE_PATH_STYLE: optionalEnvBoolean(),
// SSO / Authentik (optional) // SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(), AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(), AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -76,6 +86,12 @@ export const env = createEnv({
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET, AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN, AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
CRON_SECRET: process.env.CRON_SECRET, CRON_SECRET: process.env.CRON_SECRET,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_BUCKET: process.env.S3_BUCKET,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
S3_REGION: process.env.S3_REGION,
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
+8
View File
@@ -1,4 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import {
pdfFontFamilySchema,
type PdfFontFamily,
} from "~/lib/pdf-fonts";
export const colorModeValues = ["light", "dark", "system"] as const; export const colorModeValues = ["light", "dark", "system"] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const; export const pdfTemplateValues = ["classic", "minimal"] as const;
@@ -6,6 +10,8 @@ export const pdfTemplateValues = ["classic", "minimal"] as const;
export const colorModeSchema = z.enum(colorModeValues); export const colorModeSchema = z.enum(colorModeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues); export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export { pdfFontFamilySchema, type PdfFontFamily };
export type ColorMode = z.infer<typeof colorModeSchema>; export type ColorMode = z.infer<typeof colorModeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>; export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
@@ -14,6 +20,8 @@ export const defaultColorMode: ColorMode = "system";
export const defaultPdfSettings = { export const defaultPdfSettings = {
pdfTemplate: "classic" as PdfTemplate, pdfTemplate: "classic" as PdfTemplate,
pdfAccentColor: "#111827", pdfAccentColor: "#111827",
pdfFontFamily: "sans" as PdfFontFamily,
pdfNumericFontFamily: "mono" as PdfFontFamily,
pdfFooterText: "Professional Invoicing", pdfFooterText: "Professional Invoicing",
pdfShowLogo: true, pdfShowLogo: true,
pdfShowPageNumbers: true, pdfShowPageNumbers: true,
+28
View File
@@ -0,0 +1,28 @@
import { db } from "~/server/db";
import { auditLog } from "~/server/db/schema";
export type AuditAction =
| "user.profile_updated"
| "user.role_updated"
| "user.password_reset_sent"
| "platform.pdf_settings_updated";
export type AuditTargetType = "user" | "platform";
type LogAuditEventInput = {
actorUserId: string;
action: AuditAction;
targetType: AuditTargetType;
targetId?: string;
metadata?: Record<string, unknown>;
};
export async function logAuditEvent(input: LogAuditEventInput): Promise<void> {
await db.insert(auditLog).values({
actorUserId: input.actorUserId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadata: input.metadata,
});
}
+65 -4
View File
@@ -1,15 +1,76 @@
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie";
const MOBILE_SESSION_TOKEN_HEADER = "x-beenvoice-session-token";
const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024;
const MAX_SESSION_TOKEN_LENGTH = 255;
const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
function looksLikeSessionCookie(cookie: string): boolean {
return cookie.split(";").some((part) => {
const name =
part
.trim()
.split("=", 1)[0]
?.replace(/^__Secure-/, "") ?? "";
return (
name === "better-auth.session_token" ||
name === "better-auth.session_data" ||
name.startsWith("better-auth.session_token.") ||
name.startsWith("better-auth.session_data.") ||
name.endsWith(".session_token") ||
name.endsWith(".session_data") ||
name.includes(".session_token.") ||
name.includes(".session_data.")
);
});
}
export function headersWithAuthCookieFallback(headers: Headers): Headers {
const mobileCookie = headers.get(MOBILE_AUTH_COOKIE_HEADER)?.trim();
if (
mobileCookie &&
mobileCookie.length <= MAX_AUTH_COOKIE_HEADER_LENGTH &&
looksLikeSessionCookie(mobileCookie)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set("cookie", mobileCookie);
return nextHeaders;
}
if (headers.get("cookie")?.trim()) return headers;
const sessionToken = headers.get(MOBILE_SESSION_TOKEN_HEADER)?.trim();
if (
sessionToken &&
sessionToken.length <= MAX_SESSION_TOKEN_LENGTH &&
SESSION_TOKEN_PATTERN.test(sessionToken)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set(
"cookie",
[
`better-auth.session_token=${sessionToken}`,
`__Secure-better-auth.session_token=${sessionToken}`,
].join("; "),
);
return nextHeaders;
}
return headers;
}
export function hasSessionCookie(headers: Headers): boolean { export function hasSessionCookie(headers: Headers): boolean {
const cookie = headers.get("cookie") ?? ""; const cookie = headers.get("cookie") ?? "";
return ( if (!cookie.trim()) return false;
cookie.includes("better-auth.session_token=") ||
cookie.includes("__Secure-better-auth.session_token=") return looksLikeSessionCookie(cookie);
);
} }
export async function getOptionalServerSession(headers: Headers) { export async function getOptionalServerSession(headers: Headers) {
headers = headersWithAuthCookieFallback(headers);
if (!hasSessionCookie(headers)) { if (!hasSessionCookie(headers)) {
return null; return null;
} }
+40 -22
View File
@@ -3,8 +3,9 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { envBoolean } from "~/lib/env-boolean"; import { env } from "~/env";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin"; import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { sendPasswordResetEmail } from "~/lib/password-reset";
import { db } from "~/server/db"; import { db } from "~/server/db";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
@@ -13,7 +14,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET, process.env.AUTHENTIK_CLIENT_SECRET,
); );
const signupsDisabled = envBoolean(process.env.DISABLE_SIGNUPS); const signupsDisabled = env.DISABLE_SIGNUPS;
// Derive the authentik origin from the issuer URL so the OAuth callback is // Derive the authentik origin from the issuer URL so the OAuth callback is
// automatically trusted without needing a separate AUTHENTIK_ORIGIN env var. // automatically trusted without needing a separate AUTHENTIK_ORIGIN env var.
@@ -26,7 +27,9 @@ const staticTrustedOrigins = [
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []), ...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []), ...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://", "beenvoice://",
"exp://", ...(env.NODE_ENV === "development"
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
: []),
...(authentikOrigin ? [authentikOrigin] : []), ...(authentikOrigin ? [authentikOrigin] : []),
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []), ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
]; ];
@@ -37,6 +40,29 @@ export const auth = betterAuth({
advanced: { advanced: {
trustedProxyHeaders: true, trustedProxyHeaders: true,
}, },
rateLimit: {
enabled: true,
window: 60,
max: 100,
customRules: {
"/sign-in/email": {
window: 60,
max: 10,
},
"/sign-up/email": {
window: 60 * 60,
max: 5,
},
"/request-password-reset": {
window: 60 * 60,
max: 5,
},
"/reset-password": {
window: 60,
max: 10,
},
},
},
experimental: { experimental: {
joins: true, joins: true,
}, },
@@ -61,25 +87,7 @@ export const auth = betterAuth({
}, },
}, },
}, },
trustedOrigins: async (request) => { trustedOrigins: staticTrustedOrigins,
const origins = [...staticTrustedOrigins];
if (!request) return origins;
const origin = request.headers.get("origin");
if (origin) origins.push(origin);
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";
if (forwardedHost) {
for (const host of forwardedHost.split(",")) {
const trimmed = host.trim();
if (trimmed) origins.push(`${forwardedProto}://${trimmed}`);
}
}
return origins;
},
...(authentikEnabled && { ...(authentikEnabled && {
accountLinking: { accountLinking: {
enabled: true, enabled: true,
@@ -89,6 +97,16 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
disableSignUp: signupsDisabled, disableSignUp: signupsDisabled,
minPasswordLength: 8,
resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, token }) => {
await sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken: token,
});
},
password: { password: {
hash: async (password) => { hash: async (password) => {
const bcrypt = await import("bcryptjs"); const bcrypt = await import("bcryptjs");
+2 -1
View File
@@ -1,11 +1,12 @@
import { env } from "~/env"; import { env } from "~/env";
import { type ColorMode } from "~/lib/appearance"; import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance"; export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
export { export {
colorModeSchema, colorModeSchema,
defaultColorMode, defaultColorMode,
defaultPdfSettings, defaultPdfSettings,
pdfFontFamilySchema,
pdfTemplateSchema, pdfTemplateSchema,
} from "~/lib/appearance"; } from "~/lib/appearance";
+68
View File
@@ -0,0 +1,68 @@
export const CSV_TEMPLATE_FILENAME = "acme-january-template.csv";
export const JSON_TEMPLATE_FILENAME = "invoice-import-template.json";
/** Matches parseInvoiceCSV column expectations (date, item/description, quantity, rate). */
export const CSV_TEMPLATE = `date,item,description,quantity,rate
2024-01-15,,API development,8,125.00
2024-01-16,Design,Design review and feedback,2,125.00
1/17/24,,Documentation,4,125.00`;
/** Matches parseInvoiceJSON shape (client, issueDate, dueDate, items). */
export const JSON_TEMPLATE = JSON.stringify(
{
invoices: [
{
name: "January Services",
issueDate: "2024-01-31",
dueDate: "2024-03-01",
client: {
name: "Acme Corp",
email: "billing@acme.com",
},
items: [
{
date: "2024-01-15",
description: "API development",
quantity: 8,
rate: 125,
},
{
date: "2024-01-16",
item: "Design",
description: "Design review",
quantity: 2,
rate: 125,
},
],
},
],
},
null,
2,
);
export function downloadImportTemplate(
content: string,
filename: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function downloadCsvTemplate() {
downloadImportTemplate(CSV_TEMPLATE, CSV_TEMPLATE_FILENAME, "text/csv");
}
export function downloadJsonTemplate() {
downloadImportTemplate(
JSON_TEMPLATE,
JSON_TEMPLATE_FILENAME,
"application/json",
);
}
+369
View File
@@ -0,0 +1,369 @@
export type ImportFormat = "csv" | "json";
export interface ImportItem {
date?: Date;
description: string;
quantity: number;
rate: number;
}
export interface ImportClientRef {
name?: string;
email?: string;
}
export interface ImportInvoice {
name: string;
issueDate?: Date;
dueDate?: Date;
client?: ImportClientRef;
clientId?: string;
items: ImportItem[];
sourceFile?: string;
errors: string[];
}
const COLUMN_ALIASES: Record<string, string[]> = {
date: ["date", "item date", "work date", "service date"],
item: ["item", "title", "name", "task"],
description: ["description", "desc", "details", "work", "notes"],
quantity: ["quantity", "qty", "hours", "hour", "units", "amount hours"],
rate: ["rate", "hourly rate", "price", "unit price", "unit_rate"],
};
function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replace(/[_-]+/g, " ");
}
function resolveColumnIndex(
headers: string[],
field: keyof typeof COLUMN_ALIASES,
): number {
const aliases = COLUMN_ALIASES[field] ?? [];
for (let i = 0; i < headers.length; i++) {
const normalized = normalizeHeader(headers[i] ?? "");
if (aliases.includes(normalized)) return i;
}
return -1;
}
export function 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 === '"') {
current += '"';
i += 2;
} else {
inQuotes = !inQuotes;
i++;
}
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
i++;
} else {
current += char;
i++;
}
}
result.push(current.trim());
return result;
}
export function parseFlexibleDate(dateStr: string): Date | undefined {
const trimmed = dateStr.trim();
if (!trimmed) return undefined;
// ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) {
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
}
// M/DD/YY or M/DD/YYYY
const slashParts = trimmed.split("/");
if (slashParts.length === 3) {
const month = parseInt(slashParts[0] ?? "1", 10) - 1;
const day = parseInt(slashParts[1] ?? "1", 10);
let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000;
const d = new Date(year, month, day);
if (!isNaN(d.getTime())) return d;
}
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
return undefined;
}
function parseNumber(value: string): number {
const cleaned = value.replace(/[$,\s]/g, "");
const n = parseFloat(cleaned);
return isNaN(n) ? 0 : n;
}
function stripExtension(filename: string): string {
return filename.replace(/\.[^.]+$/, "");
}
function buildItemDescription(item: string, description: string): string {
const parts = [item.trim(), description.trim()].filter(Boolean);
return parts.join(" — ") || "Imported item";
}
function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
const itemDates = items
.map((i) => i.date)
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
return fallback ?? new Date();
}
function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
}
export function parseInvoiceCSV(
csvText: string,
filename: string,
): ImportInvoice {
const errors: string[] = [];
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
if (lines.length === 0) {
return {
name: stripExtension(filename),
items: [],
sourceFile: filename,
errors: ["File is empty"],
};
}
const headers = parseCSVLine(lines[0] ?? "");
const dateIdx = resolveColumnIndex(headers, "date");
const itemIdx = resolveColumnIndex(headers, "item");
const descIdx = resolveColumnIndex(headers, "description");
const qtyIdx = resolveColumnIndex(headers, "quantity");
const rateIdx = resolveColumnIndex(headers, "rate");
if (descIdx === -1 && itemIdx === -1) {
errors.push(
'Missing description column (expected "description" or "item")',
);
}
if (qtyIdx === -1) {
errors.push('Missing quantity column (expected "quantity" or "hours")');
}
if (rateIdx === -1) {
errors.push('Missing rate column (expected "rate" or "price")');
}
const items: ImportItem[] = [];
for (let rowIdx = 1; rowIdx < lines.length; rowIdx++) {
const values = parseCSVLine(lines[rowIdx] ?? "");
if (values.every((v) => !v.trim())) continue;
const itemText = itemIdx >= 0 ? (values[itemIdx] ?? "") : "";
const descText = descIdx >= 0 ? (values[descIdx] ?? "") : "";
const description = buildItemDescription(itemText, descText);
const quantity = qtyIdx >= 0 ? parseNumber(values[qtyIdx] ?? "0") : 0;
const rate = rateIdx >= 0 ? parseNumber(values[rateIdx] ?? "0") : 0;
if (!description || description === "Imported item") {
if (!itemText && !descText) continue;
}
if (quantity <= 0) {
errors.push(`Row ${rowIdx + 1}: quantity must be greater than 0`);
continue;
}
if (rate <= 0) {
errors.push(`Row ${rowIdx + 1}: rate must be greater than 0`);
continue;
}
let date: Date | undefined;
if (dateIdx >= 0) {
const rawDate = values[dateIdx] ?? "";
if (rawDate.trim()) {
date = parseFlexibleDate(rawDate);
if (!date) {
errors.push(`Row ${rowIdx + 1}: invalid date "${rawDate}"`);
}
}
}
items.push({ date, description, quantity, rate });
}
const issueDate = deriveIssueDate(items);
return {
name: stripExtension(filename),
issueDate,
dueDate: defaultDueDate(issueDate),
items,
sourceFile: filename,
errors:
items.length === 0 && errors.length === 0
? ["No valid line items found"]
: errors,
};
}
interface JsonInvoiceItem {
date?: string;
description?: string;
item?: string;
quantity?: number;
hours?: number;
rate?: number;
}
interface JsonInvoice {
name?: string;
invoiceNumber?: string;
issueDate?: string;
dueDate?: string;
client?: { name?: string; email?: string };
clientName?: string;
items?: JsonInvoiceItem[];
}
function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const errors: string[] = [];
const name = raw.name ?? raw.invoiceNumber ?? `Imported Invoice ${index + 1}`;
const clientName = raw.client?.name ?? raw.clientName;
const clientEmail = raw.client?.email;
const items: ImportItem[] = (raw.items ?? []).map((item, itemIdx) => {
const description = buildItemDescription(
item.item ?? "",
item.description ?? "",
);
const quantity = item.quantity ?? item.hours ?? 0;
const rate = item.rate ?? 0;
if (!description || description === "Imported item") {
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
}
if (quantity <= 0) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: quantity must be greater than 0`,
);
}
if (rate <= 0) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: rate must be greater than 0`,
);
}
let date: Date | undefined;
if (item.date) {
date = parseFlexibleDate(item.date);
if (!date) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: invalid date "${item.date}"`,
);
}
}
return { date, description, quantity, rate };
});
let issueDate: Date | undefined;
if (raw.issueDate) {
issueDate = parseFlexibleDate(raw.issueDate);
if (!issueDate) {
errors.push(`Invoice "${name}": invalid issue date "${raw.issueDate}"`);
}
}
let dueDate: Date | undefined;
if (raw.dueDate) {
dueDate = parseFlexibleDate(raw.dueDate);
if (!dueDate) {
errors.push(`Invoice "${name}": invalid due date "${raw.dueDate}"`);
}
}
const resolvedIssue = issueDate ?? deriveIssueDate(items);
const resolvedDue = dueDate ?? defaultDueDate(resolvedIssue);
if (items.length === 0) {
errors.push(`Invoice "${name}": at least one item is required`);
}
return {
name,
issueDate: resolvedIssue,
dueDate: resolvedDue,
client:
clientName || clientEmail
? { name: clientName, email: clientEmail }
: undefined,
items,
errors,
};
}
export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return [
{
name: "JSON Import",
items: [],
errors: ["Invalid JSON format"],
},
];
}
let rawInvoices: JsonInvoice[] = [];
if (Array.isArray(parsed)) {
rawInvoices = parsed as JsonInvoice[];
} else if (parsed && typeof parsed === "object") {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj.invoices)) {
rawInvoices = obj.invoices as JsonInvoice[];
} else if (obj.items || obj.name || obj.invoiceNumber) {
rawInvoices = [obj];
}
}
if (rawInvoices.length === 0) {
return [
{
name: "JSON Import",
items: [],
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
},
];
}
return rawInvoices.map((inv, idx) => normalizeJsonInvoice(inv, idx));
}
export function detectImportFormat(filename: string): ImportFormat {
return filename.toLowerCase().endsWith(".json") ? "json" : "csv";
}
+38
View File
@@ -0,0 +1,38 @@
export type LineItemBillingType = "hourly" | "fixed";
export function isFixedLineItem(hours: number): boolean {
return hours === 0;
}
export function getLineItemBillingType(hours: number): LineItemBillingType {
return isFixedLineItem(hours) ? "fixed" : "hourly";
}
export function calculateLineItemAmount(hours: number, rate: number): number {
return isFixedLineItem(hours) ? rate : hours * rate;
}
export function formatLineItemDetail(
hours: number,
rate: number,
formatCurrency: (amount: number) => string,
): string {
if (isFixedLineItem(hours)) {
return "Fixed amount";
}
return `${hours}h @ ${formatCurrency(rate)}/hr`;
}
export function applyBillingTypeChange(
billingType: LineItemBillingType,
current: { hours: number; rate: number },
): { hours: number; rate: number; amount: number } {
if (billingType === "fixed") {
const amount = calculateLineItemAmount(current.hours, current.rate);
return { hours: 0, rate: amount, amount };
}
const hours = current.hours > 0 ? current.hours : 1;
const amount = calculateLineItemAmount(hours, current.rate);
return { hours, rate: current.rate, amount };
}
+3
View File
@@ -29,6 +29,9 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
pathname.startsWith("/dashboard/businesses") pathname.startsWith("/dashboard/businesses")
); );
} }
if (href === "/dashboard/time-clock") {
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
}
return pathname === href; return pathname === href;
} }
+172
View File
@@ -0,0 +1,172 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
import path from "path";
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
function isS3Configured(): boolean {
return Boolean(
process.env.S3_BUCKET &&
process.env.S3_ACCESS_KEY &&
process.env.S3_SECRET_KEY,
);
}
export function getStorageBackend(): "s3" | "local" {
return isS3Configured() ? "s3" : "local";
}
type S3Module = typeof import("@aws-sdk/client-s3");
let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
let s3DnsHintLogged = false;
let s3BareGarageHintLogged = false;
function shouldForcePathStyle(): boolean {
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
if (override === "true" || override === "1") return true;
if (override === "false" || override === "0") return false;
return Boolean(process.env.S3_ENDPOINT);
}
function logBareGarageEndpointHint(): void {
if (s3BareGarageHintLogged || process.env.NODE_ENV !== "production") return;
const endpoint = process.env.S3_ENDPOINT;
if (!endpoint) return;
try {
const { hostname } = new URL(endpoint);
if (hostname !== "garage") return;
s3BareGarageHintLogged = true;
console.warn(
"[object-storage] S3_ENDPOINT hostname is bare 'garage'. " +
"That only resolves inside a single Docker Compose stack. " +
"Coolify Application + separate Garage compose: set S3_ENDPOINT to " +
"SERVICE_URL_GARAGE_3900 (public domain) or http://garage-<resource-uuid>:3900. " +
"See docs/COOLIFY.md.",
);
} catch {
// Invalid URL — env validation or S3 client will surface it.
}
}
function logS3DnsHint(error: unknown): void {
if (s3DnsHintLogged) return;
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
s3DnsHintLogged = true;
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
console.error(
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
"Separate Coolify stacks cannot resolve bare 'garage' — use the internal hostname from the Garage resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
);
}
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
logS3DnsHint(error);
throw error;
}
}
async function getS3() {
s3ModulePromise ??= import("@aws-sdk/client-s3");
const mod = await s3ModulePromise;
if (!s3Client) {
logBareGarageEndpointHint();
s3Client = new mod.S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
// Required for Garage and most S3-compatible endpoints (including HTTPS proxies).
forcePathStyle: shouldForcePathStyle(),
});
}
return { client: s3Client, ...mod };
}
function localPathForKey(key: string) {
return path.join(LOCAL_RECEIPTS_DIR, key);
}
export async function putObject(
key: string,
body: Buffer,
contentType: string,
): Promise<void> {
if (isS3Configured()) {
const { client, PutObjectCommand } = await getS3();
await withS3Diagnostics(() =>
client.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
}),
),
);
return;
}
const filePath = localPathForKey(key);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, body);
}
export async function getObject(key: string): Promise<Buffer> {
if (isS3Configured()) {
const { client, GetObjectCommand } = await getS3();
const response = await withS3Diagnostics(() =>
client.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) {
throw new Error("Empty object body");
}
return Buffer.from(bytes);
}
return readFile(localPathForKey(key));
}
export async function deleteObject(key: string): Promise<void> {
if (isS3Configured()) {
const { client, DeleteObjectCommand } = await getS3();
await withS3Diagnostics(() =>
client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
return;
}
try {
await unlink(localPathForKey(key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
export function isAllowedReceiptMime(mimeType: string): boolean {
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
return normalized === "application/pdf" || normalized.startsWith("image/");
}
+85
View File
@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { Resend } from "resend";
import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import {
createPasswordResetToken,
hashPasswordResetToken,
} from "~/lib/reset-token";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export type PasswordResetResult = {
success: boolean;
emailSent: boolean;
userEmail?: string;
};
export async function sendPasswordResetEmail(input: {
userEmail: string;
userName?: string;
resetToken: string;
}): Promise<PasswordResetResult> {
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: input.userEmail,
userName: input.userName,
resetToken: input.resetToken,
resetUrl,
expiryHours: 1,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: input.userEmail,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: input.userEmail };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
}
export async function sendPasswordResetForUser(
userId: string,
): Promise<PasswordResetResult> {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, name: true },
});
if (!user) {
return { success: false, emailSent: false };
}
const resetToken = createPasswordResetToken();
const resetTokenHash = hashPasswordResetToken(resetToken);
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
await db
.update(users)
.set({ resetToken: resetTokenHash, resetTokenExpiry })
.where(eq(users.id, user.id));
return sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken,
});
}
+166 -38
View File
@@ -6,9 +6,19 @@ import {
Image, Image,
StyleSheet, StyleSheet,
pdf, pdf,
type Styles,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import {
isFixedLineItem,
} from "~/lib/invoice-line-item";
import React from "react"; import React from "react";
import {
type PdfFontFamily,
type ResolvedPdfFonts,
pdfFontCacheKey,
resolvePdfFonts,
} from "~/lib/pdf-fonts";
// Fallback download function for better browser compatibility // Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void { function downloadBlob(blob: Blob, filename: string): void {
@@ -101,6 +111,8 @@ export interface InvoiceData {
export interface PDFGenerationSettings { export interface PDFGenerationSettings {
pdfTemplate?: "classic" | "minimal"; pdfTemplate?: "classic" | "minimal";
pdfAccentColor?: string; pdfAccentColor?: string;
pdfFontFamily?: PdfFontFamily;
pdfNumericFontFamily?: PdfFontFamily;
pdfFooterText?: string; pdfFooterText?: string;
pdfShowLogo?: boolean; pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean; pdfShowPageNumbers?: boolean;
@@ -109,6 +121,8 @@ export interface PDFGenerationSettings {
const defaultPDFSettings: Required<PDFGenerationSettings> = { const defaultPDFSettings: Required<PDFGenerationSettings> = {
pdfTemplate: "classic", pdfTemplate: "classic",
pdfAccentColor: "#111827", pdfAccentColor: "#111827",
pdfFontFamily: "sans",
pdfNumericFontFamily: "mono",
pdfFooterText: "Professional Invoicing", pdfFooterText: "Professional Invoicing",
pdfShowLogo: true, pdfShowLogo: true,
pdfShowPageNumbers: true, pdfShowPageNumbers: true,
@@ -118,7 +132,95 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings }; return { ...defaultPDFSettings, ...settings };
} }
const styles = StyleSheet.create({ function mapLegacyPdfFont(
fontFamily: string,
fonts: ResolvedPdfFonts,
): string {
switch (fontFamily) {
case "Helvetica-Bold":
return fonts.bold;
case "Helvetica":
return fonts.regular;
case "Courier-Bold":
return fonts.monoBold;
case "Courier":
return fonts.mono;
default:
return fontFamily;
}
}
function remapStyleFontFamilies<T extends Styles>(
sheet: T,
fonts: ResolvedPdfFonts,
): T {
const remapped = {} as T;
for (const [key, style] of Object.entries(sheet)) {
const fontFamily = (style as { fontFamily?: string }).fontFamily;
remapped[key as keyof T] = {
...style,
...(fontFamily
? { fontFamily: mapLegacyPdfFont(fontFamily, fonts) }
: {}),
} as T[keyof T];
}
return remapped;
}
type PdfStyleBundle = {
styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts;
getStatusStyle: (
status: string,
) => Array<Record<string, string | number>>;
};
const pdfStyleCache = new Map<string, PdfStyleBundle>();
function getPdfStyleBundle(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily,
): PdfStyleBundle {
const cacheKey = pdfFontCacheKey(bodyFamily, numericFamily);
const cached = pdfStyleCache.get(cacheKey);
if (cached) return cached;
const fonts = resolvePdfFonts(bodyFamily, numericFamily);
const styles = remapStyleFontFamilies(baseStyles, fonts);
const bundle: PdfStyleBundle = {
styles,
minimalStyles: baseMinimalStyles,
fonts,
getStatusStyle: (status: string) => {
switch (status.toLowerCase()) {
case "paid":
return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default:
return [styles.statusBadge, styles.statusUnpaid];
}
},
};
pdfStyleCache.set(cacheKey, bundle);
return bundle;
}
const baseStyles = StyleSheet.create({
page: { page: {
flexDirection: "column", flexDirection: "column",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
@@ -537,7 +639,7 @@ const styles = StyleSheet.create({
}, },
}); });
const minimalStyles = StyleSheet.create({ const baseMinimalStyles = StyleSheet.create({
page: { page: {
fontSize: 9, fontSize: 9,
paddingTop: 28, paddingTop: 28,
@@ -729,27 +831,6 @@ const getStatusLabel = (status: string) => {
} }
}; };
const getStatusStyle = (status: string) => {
switch (status.toLowerCase()) {
case "paid":
return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default:
return [styles.statusBadge, styles.statusUnpaid];
}
};
function getColumnWidths(showRate: boolean) { function getColumnWidths(showRate: boolean) {
return showRate return showRate
? { ? {
@@ -766,7 +847,9 @@ function getColumnWidths(showRate: boolean) {
const DenseHeader: React.FC<{ const DenseHeader: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => { pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal"; const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
@@ -1029,7 +1112,9 @@ const DenseHeader: React.FC<{
const TableHeader: React.FC<{ const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
showRate: boolean; showRate: boolean;
}> = ({ settings, showRate }) => { pdfStyles: PdfStyleBundle;
}> = ({ settings, showRate, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
const cols = getColumnWidths(showRate); const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal"; const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
@@ -1094,7 +1179,9 @@ const TableHeader: React.FC<{
const NotesSection: React.FC<{ const NotesSection: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => { pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
if (!invoice.notes) return null; if (!invoice.notes) return null;
const isMinimal = settings.pdfTemplate === "minimal"; const isMinimal = settings.pdfTemplate === "minimal";
@@ -1129,9 +1216,11 @@ const NotesSection: React.FC<{
); );
}; };
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({ const Footer: React.FC<{
settings, settings: Required<PDFGenerationSettings>;
}) => { pdfStyles: PdfStyleBundle;
}> = ({ settings, pdfStyles }) => {
const { styles, minimalStyles, fonts } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal"; const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
@@ -1151,7 +1240,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
<Text <Text
style={{ style={{
fontSize: isMinimal ? 8 : 9, fontSize: isMinimal ? 8 : 9,
fontFamily: "Helvetica", fontFamily: fonts.regular,
color: "#6b7280", color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0, marginLeft: settings.pdfShowLogo ? 8 : 0,
}} }}
@@ -1176,7 +1265,9 @@ const TotalsSection: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>; items: Array<NonNullable<InvoiceData["items"]>[0]>;
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
}> = ({ invoice, items, settings }) => { pdfStyles: PdfStyleBundle;
}> = ({ invoice, items, settings, pdfStyles }) => {
const { styles, minimalStyles, fonts } = pdfStyles;
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0); const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
@@ -1206,7 +1297,7 @@ const TotalsSection: React.FC<{
<Text <Text
style={{ style={{
fontSize: isMinimal ? 8 : 11, fontSize: isMinimal ? 8 : 11,
fontFamily: "Helvetica-Bold", fontFamily: fonts.bold,
color: "#0f0f0f", color: "#0f0f0f",
textAlign: isMinimal ? "left" : "center", textAlign: isMinimal ? "left" : "center",
marginBottom: isMinimal ? 5 : 8, marginBottom: isMinimal ? 5 : 8,
@@ -1301,6 +1392,26 @@ export const InvoicePDF: React.FC<{
settings?: PDFGenerationSettings; settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => { }> = ({ invoice, settings: inputSettings }) => {
const settings = resolvePDFSettings(inputSettings); const settings = resolvePDFSettings(inputSettings);
const pdfStyles = getPdfStyleBundle(
settings.pdfFontFamily,
settings.pdfNumericFontFamily,
);
return (
<InvoicePDFDocument
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
);
};
const InvoicePDFDocument: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
const items = invoice.items?.filter(Boolean) ?? []; const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1; const showRate = new Set(items.map((item) => item?.rate)).size > 1;
@@ -1313,7 +1424,11 @@ export const InvoicePDF: React.FC<{
size="LETTER" size="LETTER"
style={[styles.page, isMinimal ? minimalStyles.page : {}]} style={[styles.page, isMinimal ? minimalStyles.page : {}]}
> >
<DenseHeader invoice={invoice} settings={settings} /> <DenseHeader
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
{items.length > 0 && ( {items.length > 0 && (
<View <View
@@ -1322,7 +1437,11 @@ export const InvoicePDF: React.FC<{
isMinimal ? minimalStyles.tableContainer : {}, isMinimal ? minimalStyles.tableContainer : {},
]} ]}
> >
<TableHeader settings={settings} showRate={showRate} /> <TableHeader
settings={settings}
showRate={showRate}
pdfStyles={pdfStyles}
/>
{items.map( {items.map(
(item, index) => (item, index) =>
item && ( item && (
@@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{
{ width: cols.hours }, { width: cols.hours },
]} ]}
> >
{item.hours} {isFixedLineItem(item.hours) ? "—" : item.hours}
</Text> </Text>
{showRate && ( {showRate && (
<Text <Text
@@ -1404,12 +1523,21 @@ export const InvoicePDF: React.FC<{
wrap={false} wrap={false}
> >
{invoice.notes && ( {invoice.notes && (
<NotesSection invoice={invoice} settings={settings} /> <NotesSection
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
)} )}
<TotalsSection invoice={invoice} items={items} settings={settings} /> <TotalsSection
invoice={invoice}
items={items}
settings={settings}
pdfStyles={pdfStyles}
/>
</View> </View>
<Footer settings={settings} /> <Footer settings={settings} pdfStyles={pdfStyles} />
</Page> </Page>
</Document> </Document>
); );
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
/** Built-in PDF font presets (react-pdf standard fonts, no embedding required). */
export const pdfFontFamilyValues = ["sans", "serif", "mono"] as const;
export const pdfFontFamilySchema = z.enum(pdfFontFamilyValues);
export type PdfFontFamily = z.infer<typeof pdfFontFamilySchema>;
export interface ResolvedPdfFonts {
regular: string;
bold: string;
mono: string;
monoBold: string;
}
export const pdfFontFamilyOptions: {
value: PdfFontFamily;
label: string;
description: string;
}[] = [
{
value: "sans",
label: "Modern",
description: "Clean sans-serif (Helvetica).",
},
{
value: "serif",
label: "Classic",
description: "Traditional serif (Times).",
},
{
value: "mono",
label: "Monospace",
description: "Fixed-width type (Courier).",
},
];
function resolveBodyFonts(family: PdfFontFamily): Pick<ResolvedPdfFonts, "regular" | "bold"> {
switch (family) {
case "serif":
return {
regular: "Times-Roman",
bold: "Times-Bold",
};
case "mono":
return {
regular: "Courier",
bold: "Courier-Bold",
};
case "sans":
default:
return {
regular: "Helvetica",
bold: "Helvetica-Bold",
};
}
}
function resolveNumericFonts(
family: PdfFontFamily,
): Pick<ResolvedPdfFonts, "mono" | "monoBold"> {
switch (family) {
case "serif":
return {
mono: "Times-Roman",
monoBold: "Times-Bold",
};
case "mono":
return {
mono: "Courier",
monoBold: "Courier-Bold",
};
case "sans":
default:
return {
mono: "Helvetica",
monoBold: "Helvetica-Bold",
};
}
}
export function resolvePdfFonts(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily = "mono",
): ResolvedPdfFonts {
return {
...resolveBodyFonts(bodyFamily),
...resolveNumericFonts(numericFamily),
};
}
export function pdfFontCacheKey(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily,
): string {
return `${bodyFamily}:${numericFamily}`;
}
export function isPdfFontFamily(value: unknown): value is PdfFontFamily {
return pdfFontFamilySchema.safeParse(value).success;
}
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
type RateLimitRule = {
windowMs: number;
max: number;
};
type RateLimitRecord = {
count: number;
resetAt: number;
};
const buckets = new Map<string, RateLimitRecord>();
function clientIp(request: NextRequest) {
return (
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-real-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown"
);
}
export function hashRateLimitPart(value: string) {
return createHash("sha256").update(value).digest("hex");
}
export function rateLimitKey(request: NextRequest, scope: string, subject?: string) {
const parts = [scope, clientIp(request)];
if (subject) parts.push(hashRateLimitPart(subject.toLowerCase().trim()));
return parts.join(":");
}
function retryAfterSeconds(resetAt: number) {
return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
}
export function checkRateLimit(key: string, rule: RateLimitRule) {
const now = Date.now();
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + rule.windowMs });
return { allowed: true, retryAfter: 0 };
}
existing.count += 1;
if (existing.count <= rule.max) {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: retryAfterSeconds(existing.resetAt) };
}
export function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: "Too many attempts. Please wait and try again." },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Retry-After": String(retryAfter),
},
},
);
}
export function requireRateLimit(key: string, rule: RateLimitRule) {
const result = checkRateLimit(key, rule);
return result.allowed ? null : rateLimitResponse(result.retryAfter);
}
+63
View File
@@ -0,0 +1,63 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
vendor: string | null;
rawLines: string[];
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
/** Heuristic receipt field extraction from OCR or pasted text. */
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
vendor: parseVendor(rawLines),
rawLines,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function createPasswordResetToken() {
return randomBytes(32).toString("hex");
}
export function hashPasswordResetToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
+23
View File
@@ -0,0 +1,23 @@
const FALLBACK_CALLBACK_PATH = "/dashboard";
export function safeCallbackPath(value: string | null | undefined) {
if (!value) return FALLBACK_CALLBACK_PATH;
const trimmed = value.trim();
if (
!trimmed.startsWith("/") ||
trimmed.startsWith("//") ||
trimmed.includes("\\") ||
/[\u0000-\u001f\u007f]/.test(trimmed)
) {
return FALLBACK_CALLBACK_PATH;
}
try {
const url = new URL(trimmed, "https://beenvoice.local");
if (url.origin !== "https://beenvoice.local") return FALLBACK_CALLBACK_PATH;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return FALLBACK_CALLBACK_PATH;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { and, eq, ne } from "drizzle-orm";
import { db } from "~/server/db";
import { sessions } from "~/server/db/schema";
export async function revokeUserSessions(userId: string, exceptToken?: string | null) {
const condition = exceptToken
? and(eq(sessions.userId, userId), ne(sessions.token, exceptToken))
: eq(sessions.userId, userId);
await db.delete(sessions).where(condition);
}
+64
View File
@@ -0,0 +1,64 @@
export function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
export function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
export type TimeEntryListItem = {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
clientId: string | null;
invoiceId: string | null;
client?: { id: string; name: string } | null;
invoice?: {
id: string;
invoiceNumber: string;
invoicePrefix: string | null;
} | null;
};
export function groupEntriesByDate<T extends { startedAt: Date }>(
entries: T[],
): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const existing = groups.get(dateKey);
if (existing) {
existing.push(entry);
} else {
groups.set(dateKey, [entry]);
}
}
return Array.from(groups.entries()).map(([dateKey, groupEntries]) => {
const sample = new Date(groupEntries[0]!.startedAt);
const label = sample.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
return { dateKey, label, entries: groupEntries };
});
}
+16 -14
View File
@@ -1,17 +1,21 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { envBoolean } from "~/lib/env-boolean";
import { isPublicRoute } from "~/lib/public-routes"; import { isPublicRoute } from "~/lib/public-routes";
import { safeCallbackPath } from "~/lib/safe-callback-url";
function hasBetterAuthSessionCookie(request: NextRequest) {
return request.cookies.getAll().some(({ name }) => {
const cookieName = name.replace(/^__Secure-/, "");
return (
cookieName === "better-auth.session_token" ||
cookieName.startsWith("better-auth.session_token.")
);
});
}
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (pathname === "/auth/register" && envBoolean(process.env.DISABLE_SIGNUPS)) {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("signup", "disabled");
return NextResponse.redirect(signInUrl);
}
// Define API routes that should be handled separately // Define API routes that should be handled separately
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"]; const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
@@ -25,15 +29,13 @@ export function proxy(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// Check for session token in cookies (Better Auth cookie names)
const sessionToken =
request.cookies.get("better-auth.session_token")?.value ??
request.cookies.get("__Secure-better-auth.session_token")?.value;
// If no session token, redirect to sign-in // If no session token, redirect to sign-in
if (!sessionToken) { if (!hasBetterAuthSessionCookie(request)) {
const signInUrl = new URL("/auth/signin", request.url); const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("callbackUrl", request.url); signInUrl.searchParams.set(
"callbackUrl",
safeCallbackPath(`${request.nextUrl.pathname}${request.nextUrl.search}`),
);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);
} }
+59
View File
@@ -0,0 +1,59 @@
import { desc, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import type { db as Db } from "~/server/db";
import { businesses } from "~/server/db/schema";
type BusinessContext = {
db: typeof Db;
session: { user: { id: string } };
};
export async function verifyBusinessAccess(
ctx: BusinessContext,
businessId?: string | null,
) {
if (!businessId) return null;
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
return business;
}
export async function resolveDefaultBusiness(ctx: BusinessContext) {
const [defaultBusiness] = await ctx.db
.select()
.from(businesses)
.where(eq(businesses.createdById, ctx.session.user.id))
.orderBy(desc(businesses.isDefault), desc(businesses.createdAt))
.limit(1);
return defaultBusiness ?? null;
}
/** Resolve explicit businessId or fall back to the user's default business. */
export async function resolveBusinessForExpense(
ctx: BusinessContext,
businessId?: string | null,
) {
if (businessId && businessId.trim() !== "") {
return verifyBusinessAccess(ctx, businessId);
}
return resolveDefaultBusiness(ctx);
}
@@ -0,0 +1,202 @@
import { and, eq } from "drizzle-orm";
import type { db } from "~/server/db";
import { invoiceItems, invoices, timeEntries } from "~/server/db/schema";
import { resolveBillingDescription } from "~/lib/time-clock";
type Db = typeof db;
function recalculateInvoiceTotal(
items: { amount: number }[],
taxRate: number,
): number {
const subtotal = items.reduce((sum, item) => sum + item.amount, 0);
return subtotal + (subtotal * taxRate) / 100;
}
export async function findLinkedInvoiceItem(database: Db, timeEntryId: string) {
return database.query.invoiceItems.findFirst({
where: eq(invoiceItems.timeEntryId, timeEntryId),
with: {
invoice: {
columns: { id: true, taxRate: true, status: true, createdById: true },
},
},
});
}
export async function insertInvoiceLineForTimeEntry(
database: Db,
input: {
invoice: {
id: string;
invoiceNumber: string;
invoicePrefix: string | null;
taxRate: number;
items: { amount: number; position: number }[];
};
entryId: string;
description: string;
hours: number;
rate: number;
date: Date;
},
) {
const amount = input.hours * input.rate;
const maxPosition = input.invoice.items.reduce(
(m, item) => Math.max(m, item.position),
-1,
);
await database.insert(invoiceItems).values({
invoiceId: input.invoice.id,
date: input.date,
description: input.description,
hours: input.hours,
rate: input.rate,
amount,
position: maxPosition + 1,
timeEntryId: input.entryId,
});
const subtotal =
input.invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
const newTotal = subtotal + (subtotal * input.invoice.taxRate) / 100;
await database
.update(invoices)
.set({ totalAmount: newTotal, updatedAt: new Date() })
.where(eq(invoices.id, input.invoice.id));
await database
.update(timeEntries)
.set({ invoiceId: input.invoice.id, updatedAt: new Date() })
.where(eq(timeEntries.id, input.entryId));
return {
id: input.invoice.id,
invoiceNumber: input.invoice.invoiceNumber,
invoicePrefix: input.invoice.invoicePrefix ?? "#",
};
}
export async function syncLinkedInvoiceItem(
database: Db,
entry: {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
invoiceId: string | null;
},
) {
const linked = await findLinkedInvoiceItem(database, entry.id);
if (!linked?.invoice) return;
if (linked.invoice.status !== "draft") return;
const hours =
entry.hours ??
(entry.endedAt
? Math.max(
0,
(entry.endedAt.getTime() - entry.startedAt.getTime()) / 3_600_000,
)
: null);
if (hours == null || hours <= 0) return;
const rate = entry.rate ?? 0;
const amount = hours * rate;
const description = resolveBillingDescription(entry.description ?? "");
await database
.update(invoiceItems)
.set({
description,
hours,
rate,
amount,
date: entry.endedAt ?? entry.startedAt,
})
.where(eq(invoiceItems.id, linked.id));
const siblings = await database.query.invoiceItems.findMany({
where: eq(invoiceItems.invoiceId, linked.invoiceId),
columns: { amount: true },
});
await database
.update(invoices)
.set({
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
updatedAt: new Date(),
})
.where(eq(invoices.id, linked.invoiceId));
}
export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) {
const linked = await findLinkedInvoiceItem(database, timeEntryId);
if (!linked?.invoice) return;
await database.delete(invoiceItems).where(eq(invoiceItems.id, linked.id));
const siblings = await database.query.invoiceItems.findMany({
where: eq(invoiceItems.invoiceId, linked.invoiceId),
columns: { amount: true },
});
await database
.update(invoices)
.set({
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
updatedAt: new Date(),
})
.where(eq(invoices.id, linked.invoiceId));
}
export async function relinkTimeEntryToInvoice(
database: Db,
userId: string,
entry: {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
clientId: string | null;
},
invoiceId: string | null,
) {
await removeLinkedInvoiceItem(database, entry.id);
if (!invoiceId || !entry.endedAt || !entry.hours || entry.hours <= 0) {
await database
.update(timeEntries)
.set({ invoiceId: invoiceId ?? null, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id));
return null;
}
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
with: { items: true },
});
if (!invoice) return null;
return insertInvoiceLineForTimeEntry(database, {
invoice,
entryId: entry.id,
description: resolveBillingDescription(entry.description ?? ""),
hours: entry.hours,
rate: entry.rate ?? 0,
date: entry.endedAt,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { users } from "~/server/db/schema";
import type { db as database } from "~/server/db";
export async function requireAdmin(ctx: {
db: typeof database;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
+2
View File
@@ -10,6 +10,7 @@ import { paymentsRouter } from "~/server/api/routers/payments";
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices"; import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
import { apiKeysRouter } from "~/server/api/routers/apiKeys"; import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries"; import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { adminRouter } from "~/server/api/routers/admin";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
recurringInvoices: recurringInvoicesRouter, recurringInvoices: recurringInvoicesRouter,
apiKeys: apiKeysRouter, apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter, timeEntries: timeEntriesRouter,
admin: adminRouter,
}); });
// export type definition of API // export type definition of API
+287
View File
@@ -0,0 +1,287 @@
import { z } from "zod";
import { and, count, desc, eq, gte, ilike, ne, or, sql } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { logAuditEvent } from "~/lib/audit-log";
import { sendPasswordResetForUser } from "~/lib/password-reset";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { requireAdmin } from "~/server/api/require-admin";
import {
auditLog,
businesses,
clients,
invoices,
sessions,
timeEntries,
users,
} from "~/server/db/schema";
const ACTIVE_USER_DAYS = 30;
async function assertNotLastAdmin(
db: Parameters<typeof requireAdmin>[0]["db"],
userId: string,
newRole: "user" | "admin",
) {
if (newRole === "admin") return;
const target = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true },
});
if (target?.role !== "admin") return;
const [adminCount] = await db
.select({ count: count() })
.from(users)
.where(eq(users.role, "admin"));
if ((adminCount?.count ?? 0) <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot remove the last administrator",
});
}
}
export const adminRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
await requireAdmin(ctx);
const activeSince = new Date();
activeSince.setDate(activeSince.getDate() - ACTIVE_USER_DAYS);
const [
[totalUsersRow],
[activeUsersRow],
[totalInvoicesRow],
[totalBusinessesRow],
[totalClientsRow],
[totalTimeEntriesRow],
[adminCountRow],
] = await Promise.all([
ctx.db.select({ count: count() }).from(users),
ctx.db
.select({ count: sql<number>`count(distinct ${sessions.userId})::int` })
.from(sessions)
.where(gte(sessions.updatedAt, activeSince)),
ctx.db.select({ count: count() }).from(invoices),
ctx.db.select({ count: count() }).from(businesses),
ctx.db.select({ count: count() }).from(clients),
ctx.db.select({ count: count() }).from(timeEntries),
ctx.db
.select({ count: count() })
.from(users)
.where(eq(users.role, "admin")),
]);
return {
totalUsers: totalUsersRow?.count ?? 0,
activeUsers: activeUsersRow?.count ?? 0,
totalInvoices: totalInvoicesRow?.count ?? 0,
totalBusinesses: totalBusinessesRow?.count ?? 0,
totalClients: totalClientsRow?.count ?? 0,
totalTimeEntries: totalTimeEntriesRow?.count ?? 0,
adminCount: adminCountRow?.count ?? 0,
activeUserWindowDays: ACTIVE_USER_DAYS,
};
}),
listUsers: protectedProcedure
.input(
z.object({
search: z.string().optional(),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(25),
}),
)
.query(async ({ ctx, input }) => {
await requireAdmin(ctx);
const search = input.search?.trim();
const whereClause = search
? or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`),
)
: undefined;
const [items, [totalRow]] = await Promise.all([
ctx.db.query.users.findMany({
where: whereClause,
columns: {
id: true,
name: true,
email: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
},
orderBy: (usersTable, { asc }) => [asc(usersTable.createdAt)],
offset: input.offset,
limit: input.limit,
}),
ctx.db
.select({ count: count() })
.from(users)
.where(whereClause),
]);
return {
items,
total: totalRow?.count ?? 0,
offset: input.offset,
limit: input.limit,
};
}),
updateUser: protectedProcedure
.input(
z.object({
userId: z.string().min(1),
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
role: z.enum(["user", "admin"]),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
const existing = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
columns: { id: true, name: true, email: true, role: true },
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const normalizedEmail = input.email.toLowerCase();
if (normalizedEmail !== existing.email) {
const emailTaken = await ctx.db.query.users.findFirst({
where: and(
eq(users.email, normalizedEmail),
ne(users.id, input.userId),
),
columns: { id: true },
});
if (emailTaken) {
throw new TRPCError({
code: "CONFLICT",
message: "Email is already in use",
});
}
}
await assertNotLastAdmin(ctx.db, input.userId, input.role);
const changedFields: string[] = [];
if (existing.name !== input.name) changedFields.push("name");
if (existing.email !== normalizedEmail) changedFields.push("email");
if (existing.role !== input.role) changedFields.push("role");
if (changedFields.length === 0) {
return { success: true };
}
await ctx.db
.update(users)
.set({
name: input.name,
email: normalizedEmail,
role: input.role,
})
.where(eq(users.id, input.userId));
await logAuditEvent({
actorUserId: ctx.session.user.id,
action:
changedFields.includes("role") && changedFields.length === 1
? "user.role_updated"
: "user.profile_updated",
targetType: "user",
targetId: input.userId,
metadata: {
changedFields,
...(changedFields.includes("role") && {
previousRole: existing.role,
newRole: input.role,
}),
},
});
return { success: true };
}),
sendPasswordReset: protectedProcedure
.input(z.object({ userId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
columns: { id: true },
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const result = await sendPasswordResetForUser(input.userId);
await logAuditEvent({
actorUserId: ctx.session.user.id,
action: "user.password_reset_sent",
targetType: "user",
targetId: input.userId,
metadata: { emailSent: result.emailSent },
});
return {
success: result.success,
emailSent: result.emailSent,
};
}),
listAuditLog: protectedProcedure
.input(
z.object({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(25),
}),
)
.query(async ({ ctx, input }) => {
await requireAdmin(ctx);
const [entries, [totalRow]] = await Promise.all([
ctx.db.query.auditLog.findMany({
orderBy: [desc(auditLog.createdAt)],
offset: input.offset,
limit: input.limit,
with: {
actor: {
columns: { id: true, name: true },
},
},
}),
ctx.db.select({ count: count() }).from(auditLog),
]);
return {
items: entries.map((entry) => ({
id: entry.id,
action: entry.action,
targetType: entry.targetType,
targetId: entry.targetId,
metadata: entry.metadata,
createdAt: entry.createdAt,
actor: entry.actor,
})),
total: totalRow?.count ?? 0,
offset: input.offset,
limit: input.limit,
};
}),
});
+5 -22
View File
@@ -7,22 +7,11 @@ import {
getApiKeyDisplayPrefix, getApiKeyDisplayPrefix,
hashApiKey, hashApiKey,
} from "~/server/api/api-keys"; } from "~/server/api/api-keys";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { apiKeys } from "~/server/db/schema"; import { apiKeys } from "~/server/db/schema";
function requireSessionAuth(ctx: { authSource: "session" | "api-key" | "none" }) {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "API keys can only be managed from an authenticated session",
});
}
}
export const apiKeysRouter = createTRPCRouter({ export const apiKeysRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => { list: sessionProcedure.query(async ({ ctx }) => {
requireSessionAuth(ctx);
return ctx.db.query.apiKeys.findMany({ return ctx.db.query.apiKeys.findMany({
where: eq(apiKeys.userId, ctx.session.user.id), where: eq(apiKeys.userId, ctx.session.user.id),
columns: { columns: {
@@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({
}); });
}), }),
create: protectedProcedure create: sessionProcedure
.input( .input(
z.object({ z.object({
name: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(100),
@@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
if (input.expiresAt && input.expiresAt <= new Date()) { if (input.expiresAt && input.expiresAt <= new Date()) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({
return { ...apiKey, key }; return { ...apiKey, key };
}), }),
revoke: protectedProcedure revoke: sessionProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
const now = new Date(); const now = new Date();
const [apiKey] = await ctx.db const [apiKey] = await ctx.db
.update(apiKeys) .update(apiKeys)
@@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
revokeAll: protectedProcedure.mutation(async ({ ctx }) => { revokeAll: sessionProcedure.mutation(async ({ ctx }) => {
requireSessionAuth(ctx);
const now = new Date(); const now = new Date();
await ctx.db await ctx.db
.update(apiKeys) .update(apiKeys)
+30 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq, gte, lt } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema"; import { clients, invoices } from "~/server/db/schema";
@@ -172,6 +172,7 @@ export const dashboardRouter = createTRPCRouter({
userInvoices, userInvoices,
userClientsCount, userClientsCount,
recentInvoices, recentInvoices,
monthInvoices,
currentDraft, currentDraft,
] = await Promise.all([ ] = await Promise.all([
ctx.db.query.invoices.findMany({ ctx.db.query.invoices.findMany({
@@ -199,6 +200,33 @@ export const dashboardRouter = createTRPCRouter({
}, },
}, },
}), }),
ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)),
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoicePrefix: true,
invoiceNumber: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
currency: true,
},
with: {
client: {
columns: { name: true },
},
},
}),
ctx.db.query.invoices.findFirst({ ctx.db.query.invoices.findFirst({
where: and( where: and(
eq(invoices.createdById, userId), eq(invoices.createdById, userId),
@@ -227,6 +255,7 @@ export const dashboardRouter = createTRPCRouter({
...metrics, ...metrics,
totalClients: userClientsCount, totalClients: userClientsCount,
recentInvoices, recentInvoices,
monthInvoices,
currentDraft: currentDraft currentDraft: currentDraft
? { ? {
id: currentDraft.id, id: currentDraft.id,
+12 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { Resend } from "resend"; import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema"; import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "~/env"; import { env } from "~/env";
@@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) {
} }
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure sendInvoice: sessionProcedure
.input( .input(
z.object({ z.object({
invoiceId: z.string(), invoiceId: z.string(),
@@ -94,6 +94,16 @@ export const emailRouter = createTRPCRouter({
| "minimal" | "minimal"
| undefined, | undefined,
pdfAccentColor: settings?.pdfAccentColor, pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText, pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
+341 -55
View File
@@ -1,9 +1,25 @@
import { z } from "zod"; import { z } from "zod";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { expenses, clients, businesses, invoices } from "~/server/db/schema"; import {
expenses,
clients,
invoices,
expenseReceipts,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
resolveBusinessForExpense,
verifyBusinessAccess,
} from "~/server/api/lib/business-access";
import {
deleteObject,
isAllowedReceiptMime,
putObject,
RECEIPT_MAX_BYTES,
} from "~/lib/object-storage";
import { parseReceiptText } from "~/lib/receipt-parse";
export { EXPENSE_CATEGORIES }; export { EXPENSE_CATEGORIES };
@@ -26,13 +42,158 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
id: z.string(), id: z.string(),
}); });
async function verifyClientAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
clientId: string,
) {
const client = await ctx.db.query.clients.findFirst({
where: and(
eq(clients.id, clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
}
return client;
}
async function verifyInvoiceAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
invoiceId: string,
) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
),
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found",
});
}
return invoice;
}
async function resolveExpenseBusinessId(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
businessId: string | null,
invoice?: { businessId: string | null } | null,
) {
const explicitBusinessId =
businessId && businessId.trim() !== "" ? businessId : null;
const inheritedBusinessId =
!explicitBusinessId && invoice?.businessId ? invoice.businessId : null;
const resolved = await resolveBusinessForExpense(
ctx,
explicitBusinessId ?? inheritedBusinessId,
);
return resolved?.id ?? null;
}
async function getOwnedExpense(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
expenseId: string,
) {
const expense = await ctx.db.query.expenses.findFirst({
where: and(
eq(expenses.id, expenseId),
eq(expenses.createdById, ctx.session.user.id),
),
});
if (!expense) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
}
return expense;
}
export const expensesRouter = createTRPCRouter({ export const expensesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure
return await ctx.db.query.expenses.findMany({ .input(
where: eq(expenses.createdById, ctx.session.user.id), z
with: { client: true, business: true, invoice: true }, .object({
businessId: z.string().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [eq(expenses.createdById, ctx.session.user.id)];
if (input?.businessId) {
await verifyBusinessAccess(ctx, input.businessId);
conditions.push(eq(expenses.businessId, input.businessId));
}
const rows = await ctx.db.query.expenses.findMany({
where: and(...conditions),
with: {
client: true,
business: true,
invoice: true,
},
orderBy: [desc(expenses.date)], orderBy: [desc(expenses.date)],
}); });
const expenseIds = rows.map((e) => e.id);
if (expenseIds.length === 0) return [];
const receiptMeta = await ctx.db
.select({
expenseId: expenseReceipts.expenseId,
id: expenseReceipts.id,
mimeType: expenseReceipts.mimeType,
originalFilename: expenseReceipts.originalFilename,
createdAt: expenseReceipts.createdAt,
})
.from(expenseReceipts)
.where(inArray(expenseReceipts.expenseId, expenseIds))
.orderBy(desc(expenseReceipts.createdAt));
const receiptStats = new Map<
string,
{
receiptCount: number;
receiptPreview: {
id: string;
mimeType: string;
originalFilename: string;
} | null;
}
>();
for (const receipt of receiptMeta) {
const existing = receiptStats.get(receipt.expenseId);
if (existing) {
existing.receiptCount += 1;
} else {
receiptStats.set(receipt.expenseId, {
receiptCount: 1,
receiptPreview: {
id: receipt.id,
mimeType: receipt.mimeType,
originalFilename: receipt.originalFilename,
},
});
}
}
return rows.map((expense) => {
const stats = receiptStats.get(expense.id);
return {
...expense,
receiptCount: stats?.receiptCount ?? 0,
receiptPreview: stats?.receiptPreview ?? null,
};
});
}), }),
getById: protectedProcedure getById: protectedProcedure
@@ -43,7 +204,12 @@ export const expensesRouter = createTRPCRouter({
eq(expenses.id, input.id), eq(expenses.id, input.id),
eq(expenses.createdById, ctx.session.user.id), eq(expenses.createdById, ctx.session.user.id),
), ),
with: { client: true, business: true, invoice: true }, with: {
client: true,
business: true,
invoice: true,
receipts: true,
},
}); });
if (!expense) { if (!expense) {
@@ -69,50 +235,26 @@ export const expensesRouter = createTRPCRouter({
}; };
if (clean.clientId) { if (clean.clientId) {
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, clean.clientId);
where: and(
eq(clients.id, clean.clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
} }
if (clean.businessId) { const invoice = clean.invoiceId
const business = await ctx.db.query.businesses.findFirst({ ? await verifyInvoiceAccess(ctx, clean.invoiceId)
where: and( : null;
eq(businesses.id, clean.businessId),
eq(businesses.createdById, ctx.session.user.id),
),
});
if (!business)
throw new TRPCError({
code: "FORBIDDEN",
message: "Business not found",
});
}
if (clean.invoiceId) { const businessId = await resolveExpenseBusinessId(
const invoice = await ctx.db.query.invoices.findFirst({ ctx,
where: and( clean.businessId,
eq(invoices.id, clean.invoiceId), invoice,
eq(invoices.createdById, ctx.session.user.id), );
),
});
if (!invoice)
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found",
});
}
const [expense] = await ctx.db const [expense] = await ctx.db
.insert(expenses) .insert(expenses)
.values({ ...clean, createdById: ctx.session.user.id }) .values({
...clean,
businessId,
createdById: ctx.session.user.id,
})
.returning(); .returning();
return expense; return expense;
@@ -137,17 +279,56 @@ export const expensesRouter = createTRPCRouter({
}); });
} }
const clean = { const updates: Record<string, unknown> = { updatedAt: new Date() };
...data,
clientId: data.clientId?.trim() ?? null,
businessId: data.businessId?.trim() ?? null,
invoiceId: data.invoiceId?.trim() ?? null,
category: data.category?.trim() ?? null,
notes: data.notes?.trim() ?? null,
updatedAt: new Date(),
};
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id)); if (data.date !== undefined) updates.date = data.date;
if (data.description !== undefined) updates.description = data.description;
if (data.amount !== undefined) updates.amount = data.amount;
if (data.currency !== undefined) updates.currency = data.currency;
if (data.billable !== undefined) updates.billable = data.billable;
if (data.reimbursable !== undefined) updates.reimbursable = data.reimbursable;
if (data.taxDeductible !== undefined) updates.taxDeductible = data.taxDeductible;
if (data.category !== undefined) {
updates.category = data.category?.trim() ?? null;
}
if (data.notes !== undefined) {
updates.notes = data.notes?.trim() ?? null;
}
const nextClientId =
data.clientId !== undefined ? data.clientId?.trim() || null : existing.clientId;
if (data.clientId !== undefined) {
if (nextClientId) await verifyClientAccess(ctx, nextClientId);
updates.clientId = nextClientId;
}
const nextInvoiceId =
data.invoiceId !== undefined
? data.invoiceId?.trim() || null
: existing.invoiceId;
let invoice = null;
if (data.invoiceId !== undefined) {
invoice = nextInvoiceId
? await verifyInvoiceAccess(ctx, nextInvoiceId)
: null;
updates.invoiceId = nextInvoiceId;
} else if (nextInvoiceId) {
invoice = await verifyInvoiceAccess(ctx, nextInvoiceId);
}
if (data.businessId !== undefined || data.invoiceId !== undefined) {
const nextBusinessInput =
data.businessId !== undefined
? data.businessId?.trim() || null
: existing.businessId;
updates.businessId = await resolveExpenseBusinessId(
ctx,
nextBusinessInput,
invoice,
);
}
await ctx.db.update(expenses).set(updates).where(eq(expenses.id, id));
return { success: true }; return { success: true };
}), }),
@@ -160,6 +341,7 @@ export const expensesRouter = createTRPCRouter({
eq(expenses.id, input.id), eq(expenses.id, input.id),
eq(expenses.createdById, ctx.session.user.id), eq(expenses.createdById, ctx.session.user.id),
), ),
with: { receipts: true },
}); });
if (!existing) { if (!existing) {
@@ -169,8 +351,112 @@ export const expensesRouter = createTRPCRouter({
}); });
} }
await Promise.all(
existing.receipts.map((receipt) => deleteObject(receipt.storageKey)),
);
await ctx.db.delete(expenses).where(eq(expenses.id, input.id)); await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
return { success: true }; return { success: true };
}), }),
listReceipts: protectedProcedure
.input(z.object({ expenseId: z.string() }))
.query(async ({ ctx, input }) => {
await getOwnedExpense(ctx, input.expenseId);
return ctx.db.query.expenseReceipts.findMany({
where: eq(expenseReceipts.expenseId, input.expenseId),
orderBy: [desc(expenseReceipts.createdAt)],
});
}),
uploadReceipt: protectedProcedure
.input(
z.object({
expenseId: z.string(),
filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100),
data: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
await getOwnedExpense(ctx, input.expenseId);
if (!isAllowedReceiptMime(input.mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only images and PDF files are allowed",
});
}
const body = Buffer.from(input.data, "base64");
if (body.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "File is empty",
});
}
if (body.length > RECEIPT_MAX_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "File exceeds 10MB limit",
});
}
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storageKey = `receipts/${ctx.session.user.id}/${input.expenseId}/${crypto.randomUUID()}-${safeName}`;
await putObject(storageKey, body, input.mimeType);
const [receipt] = await ctx.db
.insert(expenseReceipts)
.values({
expenseId: input.expenseId,
storageKey,
originalFilename: input.filename,
mimeType: input.mimeType,
sizeBytes: body.length,
})
.returning();
return receipt;
}),
deleteReceipt: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const receipt = await ctx.db.query.expenseReceipts.findFirst({
where: eq(expenseReceipts.id, input.id),
with: { expense: true },
});
if (
receipt?.expense.createdById !== ctx.session.user.id
) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Receipt not found",
});
}
await deleteObject(receipt.storageKey);
await ctx.db
.delete(expenseReceipts)
.where(eq(expenseReceipts.id, input.id));
return { success: true };
}),
suggestFromReceiptText: protectedProcedure
.input(z.object({ text: z.string().min(1).max(20_000) }))
.mutation(({ input }) => {
const parsed = parseReceiptText(input.text);
return {
amount: parsed.amount,
date: parsed.date,
description: parsed.vendor,
rawLines: parsed.rawLines,
};
}),
}); });
+285 -10
View File
@@ -1,6 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, desc, eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
sessionProcedure,
} from "../trpc";
import { import {
invoices, invoices,
invoiceItems, invoiceItems,
@@ -9,7 +14,9 @@ import {
platformSettings, platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend"; import { Resend } from "resend";
import { env } from "~/env"; import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email"; import { NOREPLY_EMAIL } from "~/lib/app-email";
@@ -21,12 +28,29 @@ type InvoiceRouterContext = {
session: { user: { id: string } }; session: { user: { id: string } };
}; };
const invoiceItemSchema = z.object({ const invoiceItemSchema = z
.object({
date: z.date(), date: z.date(),
description: z.string().min(1, "Description is required"), description: z.string().min(1, "Description is required"),
hours: z.number().min(0, "Hours must be positive"), hours: z.number().min(0, "Hours must be positive"),
rate: z.number().min(0, "Rate must be positive"), rate: z.number().min(0, "Rate must be positive"),
}); })
.superRefine((item, ctx) => {
if (item.hours === 0 && item.rate <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Fixed line items need an amount greater than zero",
path: ["rate"],
});
}
if (item.hours > 0 && item.rate <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Hourly line items need a rate greater than zero",
path: ["rate"],
});
}
});
const createInvoiceSchema = z.object({ const createInvoiceSchema = z.object({
invoiceNumber: z.string().min(1, "Invoice number is required"), invoiceNumber: z.string().min(1, "Invoice number is required"),
@@ -57,6 +81,39 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]), status: z.enum(["draft", "sent", "paid"]),
}); });
const bulkImportItemSchema = z.object({
date: z.coerce.date().optional(),
description: z.string().min(1, "Description is required"),
quantity: z.number().min(0, "Quantity must be positive"),
rate: z.number().min(0, "Rate must be positive"),
});
const bulkImportClientSchema = z.object({
name: z.string().optional(),
email: z.string().email("Invalid client email").optional().or(z.literal("")),
});
const bulkImportInvoiceSchema = z.object({
name: z.string().min(1, "Invoice name is required"),
issueDate: z.coerce.date().optional(),
dueDate: z.coerce.date().optional(),
clientId: z.string().optional(),
client: bulkImportClientSchema.optional(),
items: z
.array(bulkImportItemSchema)
.min(1, "At least one line item is required"),
notes: z.string().optional(),
sourceFile: z.string().optional(),
});
const bulkImportSchema = z.object({
defaultClientId: z.string().optional(),
defaultBusinessId: z.string().optional().or(z.literal("")),
invoices: z.array(bulkImportInvoiceSchema).min(1, "No invoices to import"),
});
type BulkImportInvoiceInput = z.infer<typeof bulkImportInvoiceSchema>;
async function verifyBusinessAccess( async function verifyBusinessAccess(
ctx: InvoiceRouterContext, ctx: InvoiceRouterContext,
businessId?: string | null, businessId?: string | null,
@@ -128,11 +185,52 @@ const calculateInvoiceTotal = (
items: Array<z.infer<typeof invoiceItemSchema>>, items: Array<z.infer<typeof invoiceItemSchema>>,
taxRate: number, taxRate: number,
) => { ) => {
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0); const subtotal = items.reduce(
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0,
);
const taxAmount = (subtotal * taxRate) / 100; const taxAmount = (subtotal * taxRate) / 100;
return subtotal + taxAmount; return subtotal + taxAmount;
}; };
type ClientRecord = typeof clients.$inferSelect;
function findExistingClient(
userClients: ClientRecord[],
clientRef?: { name?: string; email?: string },
): ClientRecord | undefined {
if (!clientRef) return undefined;
if (clientRef.email?.trim()) {
const email = clientRef.email.trim().toLowerCase();
const byEmail = userClients.find(
(c) => c.email?.toLowerCase() === email,
);
if (byEmail) return byEmail;
}
if (clientRef.name?.trim()) {
const name = clientRef.name.trim().toLowerCase();
const byName = userClients.find((c) => c.name.toLowerCase() === name);
if (byName) return byName;
}
return undefined;
}
function deriveIssueDateFromItems(
items: BulkImportInvoiceInput["items"],
fallback?: Date,
): Date {
const itemDates = items
.map((i) => i.date)
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
return fallback ?? new Date();
}
export const invoicesRouter = createTRPCRouter({ export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure getAll: protectedProcedure
.input( .input(
@@ -373,7 +471,7 @@ export const invoicesRouter = createTRPCRouter({
items.map((item, idx) => ({ items.map((item, idx) => ({
...item, ...item,
invoiceId: invoice.id, invoiceId: invoice.id,
amount: item.hours * item.rate, amount: calculateLineItemAmount(item.hours, item.rate),
position: idx, position: idx,
})), })),
); );
@@ -491,7 +589,7 @@ export const invoicesRouter = createTRPCRouter({
items.map((item, idx) => ({ items.map((item, idx) => ({
...item, ...item,
invoiceId: id, invoiceId: id,
amount: item.hours * item.rate, amount: calculateLineItemAmount(item.hours, item.rate),
position: idx, position: idx,
})), })),
); );
@@ -661,6 +759,173 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length }; return { success: true, deleted: ownedIds.length };
}), }),
bulkImport: sessionProcedure
.input(bulkImportSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const business = await resolveBusinessForInvoice(
ctx,
input.defaultBusinessId,
);
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"No business found. Create a business in Settings before importing invoices.",
});
}
if (input.defaultClientId) {
await verifyClientAccess(ctx, input.defaultClientId);
}
if (input.defaultBusinessId && input.defaultBusinessId.trim() !== "") {
await verifyBusinessAccess(ctx, input.defaultBusinessId);
}
let invoicesCreated = 0;
let clientsCreated = 0;
const rowErrors: string[] = [];
try {
await ctx.db.transaction(async (tx) => {
const userClients = await tx
.select()
.from(clients)
.where(eq(clients.createdById, userId));
for (let i = 0; i < input.invoices.length; i++) {
const inv = input.invoices[i]!;
const label = inv.sourceFile ?? inv.name ?? `Invoice ${i + 1}`;
try {
let clientId = inv.clientId ?? input.defaultClientId;
if (!clientId) {
const existing = findExistingClient(userClients, inv.client);
if (existing) {
clientId = existing.id;
} else if (inv.client?.name?.trim()) {
const [newClient] = await tx
.insert(clients)
.values({
name: inv.client.name.trim(),
email:
inv.client.email && inv.client.email.trim() !== ""
? inv.client.email.trim()
: null,
createdById: userId,
})
.returning();
if (!newClient) {
rowErrors.push(`${label}: failed to create client`);
continue;
}
userClients.push(newClient);
clientId = newClient.id;
clientsCreated++;
}
}
if (!clientId) {
rowErrors.push(
`${label}: no client specified (select a default client or include client details in JSON)`,
);
continue;
}
const clientRecord = userClients.find((c) => c.id === clientId);
if (!clientRecord) {
rowErrors.push(`${label}: client not found`);
continue;
}
const issueDate =
inv.issueDate ?? deriveIssueDateFromItems(inv.items);
const dueDate = inv.dueDate ?? defaultDueDate(issueDate);
const dbItems = inv.items.map((item) => ({
date: item.date ?? issueDate,
description: item.description,
hours: item.quantity,
rate: item.rate,
}));
const totalAmount = calculateInvoiceTotal(dbItems, 0);
const invoiceNumber =
inv.name.trim().slice(0, 100) || generateInvoiceNumber();
const notes =
inv.notes ??
(inv.sourceFile
? `Imported from ${inv.sourceFile}`
: "Imported invoice");
const [invoice] = await tx
.insert(invoices)
.values({
invoiceNumber,
businessId: business.id,
clientId,
issueDate,
dueDate,
status: "draft",
totalAmount,
taxRate: 0,
currency: "USD",
notes,
createdById: userId,
})
.returning();
if (!invoice) {
rowErrors.push(`${label}: failed to create invoice`);
continue;
}
await tx.insert(invoiceItems).values(
dbItems.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: calculateLineItemAmount(item.hours, item.rate),
position: idx,
})),
);
invoicesCreated++;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Unknown error";
rowErrors.push(`${label}: ${msg}`);
}
}
});
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to import invoices",
cause: error,
});
}
if (invoicesCreated === 0 && rowErrors.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: rowErrors.join("\n"),
});
}
return {
success: true,
invoicesCreated,
clientsCreated,
errors: rowErrors,
};
}),
previewPdf: protectedProcedure previewPdf: protectedProcedure
.input(createInvoiceSchema) .input(createInvoiceSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -696,7 +961,7 @@ export const invoicesRouter = createTRPCRouter({
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: calculateLineItemAmount(item.hours, item.rate),
})), })),
}, },
{ {
@@ -705,6 +970,16 @@ export const invoicesRouter = createTRPCRouter({
| "minimal" | "minimal"
| undefined, | undefined,
pdfAccentColor: settings?.pdfAccentColor, pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText, pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
@@ -728,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Public token (shareable link) ────────────────────────────────────────── // ── Public token (shareable link) ──────────────────────────────────────────
generatePublicToken: protectedProcedure generatePublicToken: sessionProcedure
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() })) .input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
@@ -748,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({
return { token, expiresAt }; return { token, expiresAt };
}), }),
revokePublicToken: protectedProcedure revokePublicToken: sessionProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
@@ -790,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Send reminder ────────────────────────────────────────────────────────── // ── Send reminder ──────────────────────────────────────────────────────────
sendReminder: protectedProcedure sendReminder: sessionProcedure
.input(z.object({ id: z.string(), customMessage: z.string().optional() })) .input(z.object({ id: z.string(), customMessage: z.string().optional() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({

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