Compare commits
19
Commits
3fb61ff4dd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3b1362a9 | ||
|
|
40624f6e6f | ||
|
|
d589b94580 | ||
|
|
da219b287e | ||
|
|
316edb4b3e | ||
|
|
8866335c50 | ||
|
|
fb731885de | ||
|
|
1605322bc9 | ||
|
|
ef826dffa9 | ||
|
|
d0c916d659 | ||
|
|
a66d05f24f | ||
|
|
122a3c64f5 | ||
|
|
31151e7f39 | ||
|
|
c267b3e1c8 | ||
|
|
5e6e51337a | ||
|
|
b9a9b813d2 | ||
|
|
85df7c4627 | ||
|
|
6b73c32c25 | ||
|
|
5978c8d903 |
+41
-6
@@ -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)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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*
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Garage-only stack for Coolify when beenvoice runs as a separate Application resource.
|
||||||
|
#
|
||||||
|
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml
|
||||||
|
#
|
||||||
|
# ── Pair with a beenvoice Application (pick ONE) ───────────────────────────────
|
||||||
|
#
|
||||||
|
# A) Public Garage URL (most reliable — no shared Docker network required)
|
||||||
|
# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below).
|
||||||
|
# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com).
|
||||||
|
# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab.
|
||||||
|
# 4. beenvoice Application → S3_ENDPOINT=<that URL> → redeploy beenvoice.
|
||||||
|
#
|
||||||
|
# B) Internal Docker DNS (same Coolify destination network)
|
||||||
|
# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy.
|
||||||
|
# 2. beenvoice Application → same destination → enable "Connect to Predefined Network".
|
||||||
|
# 3. beenvoice → S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
|
||||||
|
#
|
||||||
|
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage).
|
||||||
|
# See docs/COOLIFY.md.
|
||||||
|
services:
|
||||||
|
garage:
|
||||||
|
image: dxflrs/garage:v2.3.0
|
||||||
|
environment:
|
||||||
|
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
SERVICE_FQDN_GARAGE_3900:
|
||||||
|
configs:
|
||||||
|
- source: garage_config
|
||||||
|
target: /etc/garage.toml
|
||||||
|
volumes:
|
||||||
|
- beenvoice_garage_meta:/var/lib/garage/meta
|
||||||
|
- beenvoice_garage_data:/var/lib/garage/data
|
||||||
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
|
expose:
|
||||||
|
- "3900"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/garage", "status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
beenvoice_garage_meta:
|
||||||
|
beenvoice_garage_data:
|
||||||
|
|
||||||
|
configs:
|
||||||
|
garage_config:
|
||||||
|
content: |
|
||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "sqlite"
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "garage:3901"
|
||||||
|
rpc_secret = "rpc_secret_change_me_in_production"
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = ".s3.garage"
|
||||||
|
|
||||||
|
[s3_web]
|
||||||
|
bind_addr = "[::]:3902"
|
||||||
|
root_domain = ".web.garage"
|
||||||
|
index = "index.html"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
|
||||||
|
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# beenvoice on Coolify — single Docker Compose resource (recommended).
|
||||||
|
#
|
||||||
|
# Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml
|
||||||
|
#
|
||||||
|
# 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik).
|
||||||
|
# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example).
|
||||||
|
# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network.
|
||||||
|
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
|
||||||
|
#
|
||||||
|
# Migrating from Application + separate Postgres + Garage compose:
|
||||||
|
# - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here.
|
||||||
|
# - Or keep external Postgres and remove the `db` service + volume from this file.
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
|
||||||
|
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
|
||||||
|
image: ${BEENVOICE_IMAGE:-beenvoice:coolify}
|
||||||
|
environment:
|
||||||
|
SERVICE_FQDN_APP:
|
||||||
|
NODE_ENV: production
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||||
|
DB_DISABLE_SSL: "true"
|
||||||
|
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
||||||
|
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
|
||||||
|
CRON_SECRET: ${CRON_SECRET:-}
|
||||||
|
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
||||||
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
|
S3_ENDPOINT: http://garage:3900
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-garage}
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
garage:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
volumes:
|
||||||
|
- beenvoice_pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
garage:
|
||||||
|
image: dxflrs/garage:v2.3.0
|
||||||
|
environment:
|
||||||
|
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
SERVICE_FQDN_GARAGE_3900:
|
||||||
|
configs:
|
||||||
|
- source: garage_config
|
||||||
|
target: /etc/garage.toml
|
||||||
|
volumes:
|
||||||
|
- beenvoice_garage_meta:/var/lib/garage/meta
|
||||||
|
- beenvoice_garage_data:/var/lib/garage/data
|
||||||
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
|
expose:
|
||||||
|
- "3900"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/garage", "status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
beenvoice_pg_data:
|
||||||
|
beenvoice_garage_meta:
|
||||||
|
beenvoice_garage_data:
|
||||||
|
|
||||||
|
configs:
|
||||||
|
garage_config:
|
||||||
|
content: |
|
||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "sqlite"
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "garage:3901"
|
||||||
|
rpc_secret = "rpc_secret_change_me_in_production"
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = ".s3.garage"
|
||||||
|
|
||||||
|
[s3_web]
|
||||||
|
bind_addr = "[::]:3902"
|
||||||
|
root_domain = ".web.garage"
|
||||||
|
index = "index.html"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
|
||||||
|
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
|
||||||
@@ -17,5 +17,58 @@ services:
|
|||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${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
@@ -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 (~50–100 MB RAM vs MinIO). API :3900.
|
||||||
|
garage:
|
||||||
|
image: dxflrs/garage:v2.3.0
|
||||||
|
environment:
|
||||||
|
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||||
|
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||||
|
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
configs:
|
||||||
|
- source: garage_config
|
||||||
|
target: /etc/garage.toml
|
||||||
|
volumes:
|
||||||
|
- beenvoice_garage_meta:/var/lib/garage/meta
|
||||||
|
- beenvoice_garage_data:/var/lib/garage/data
|
||||||
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
|
ports:
|
||||||
|
- "${GARAGE_API_PORT:-3900}:3900"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/garage", "status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
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"
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,137 @@
|
|||||||
|
# Coolify deployment — beenvoice + Garage
|
||||||
|
|
||||||
|
beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. [Garage](https://garagehq.deuxfleurs.fr/) is the default on self-hosted Coolify (~50–100 MB RAM vs MinIO's ~500 MB+).
|
||||||
|
|
||||||
|
## Why `getaddrinfo ENOTFOUND garage` happens
|
||||||
|
|
||||||
|
Docker DNS resolves service names **only inside the same Docker network**.
|
||||||
|
|
||||||
|
| Setup | Does `http://garage:3900` work? |
|
||||||
|
|-------|--------------------------------|
|
||||||
|
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
|
||||||
|
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
|
||||||
|
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
|
||||||
|
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed |
|
||||||
|
|
||||||
|
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
|
||||||
|
|
||||||
|
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick fix — keep beenvoice as Application + separate Garage compose
|
||||||
|
|
||||||
|
Use this if you are **not** migrating to a single Compose stack today.
|
||||||
|
|
||||||
|
### Path A — public Garage URL (recommended, works without shared Docker network)
|
||||||
|
|
||||||
|
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
|
||||||
|
|
||||||
|
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource.
|
||||||
|
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
|
||||||
|
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`).
|
||||||
|
4. On the **beenvoice Application** → Environment:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=https://s3.yourdomain.com
|
||||||
|
S3_BUCKET=beenvoice-receipts
|
||||||
|
S3_ACCESS_KEY=<same as GARAGE_DEFAULT_ACCESS_KEY / S3_ACCESS_KEY on Garage stack>
|
||||||
|
S3_SECRET_KEY=<same as GARAGE_DEFAULT_SECRET_KEY / S3_SECRET_KEY on Garage stack>
|
||||||
|
S3_REGION=garage
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Redeploy beenvoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
|
||||||
|
|
||||||
|
`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for Garage behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets.
|
||||||
|
|
||||||
|
### Path B — internal Docker DNS (same destination, no public Garage domain)
|
||||||
|
|
||||||
|
Use when you want S3 API traffic to stay on the Docker network.
|
||||||
|
|
||||||
|
1. Put beenvoice Application and Garage Compose in the **same Coolify project** and **same destination** (server/network).
|
||||||
|
2. **Garage Compose resource** → **Advanced** → enable **Connect to Predefined Network** → **redeploy Garage**.
|
||||||
|
3. **beenvoice Application** → **Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy beenvoice**.
|
||||||
|
4. Find the Garage resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the Garage container).
|
||||||
|
5. Set on beenvoice Application:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: resource UUID `k8w2o0g4s0g8` → `S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
|
||||||
|
|
||||||
|
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A).
|
||||||
|
|
||||||
|
6. Match credentials and bucket:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_BUCKET=beenvoice-receipts
|
||||||
|
S3_ACCESS_KEY=<S3_ACCESS_KEY on Garage stack>
|
||||||
|
S3_SECRET_KEY=<S3_SECRET_KEY on Garage stack>
|
||||||
|
S3_REGION=garage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended long-term — one Compose stack
|
||||||
|
|
||||||
|
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
|
||||||
|
|
||||||
|
1. Coolify → **New Resource** → **Docker Compose**
|
||||||
|
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
|
||||||
|
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc.
|
||||||
|
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
|
||||||
|
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
|
||||||
|
6. Redeploy.
|
||||||
|
|
||||||
|
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
|
||||||
|
|
||||||
|
### Migrating from Application + external Postgres + Garage (or legacy MinIO)
|
||||||
|
|
||||||
|
| Current | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| beenvoice Application | Remove after Compose stack is live |
|
||||||
|
| Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file |
|
||||||
|
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
|
||||||
|
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
|
||||||
|
|
||||||
|
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compose file reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
|
||||||
|
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
|
||||||
|
| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
|
||||||
|
|
||||||
|
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist (Application + separate Garage)
|
||||||
|
|
||||||
|
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
|
||||||
|
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900`
|
||||||
|
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900`
|
||||||
|
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
|
||||||
|
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
|
||||||
|
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
|
||||||
|
- [ ] Redeployed beenvoice after env or network changes
|
||||||
|
|
||||||
|
## Verify from the beenvoice container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shell into beenvoice app container on the Coolify server
|
||||||
|
docker exec -it <beenvoice-container> sh
|
||||||
|
|
||||||
|
# Path A — public URL (403/404 on root is fine — confirms DNS + TLS)
|
||||||
|
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
|
||||||
|
|
||||||
|
# Path B — internal host from S3_ENDPOINT
|
||||||
|
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900"
|
||||||
|
```
|
||||||
|
|
||||||
|
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
|
||||||
| [../README.md](../README.md) | Install, scripts, deployment |
|
| [../README.md](../README.md) | Install, scripts, deployment |
|
||||||
|
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
|
||||||
|
|
||||||
## UI & product guides
|
## UI & product guides
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_audit_log" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"actorUserId" varchar(255) NOT NULL,
|
||||||
|
"action" varchar(100) NOT NULL,
|
||||||
|
"targetType" varchar(50) NOT NULL,
|
||||||
|
"targetId" varchar(255),
|
||||||
|
"metadata" jsonb,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_audit_log"
|
||||||
|
ADD CONSTRAINT "beenvoice_audit_log_actorUserId_beenvoice_user_id_fk"
|
||||||
|
FOREIGN KEY ("actorUserId") REFERENCES "public"."beenvoice_user"("id")
|
||||||
|
ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx" ON "beenvoice_audit_log" USING btree ("actorUserId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "beenvoice_audit_log" USING btree ("action");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx" ON "beenvoice_audit_log" USING btree ("createdAt");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS "expense_business_id_idx" ON "beenvoice_expense" USING btree ("businessId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_expense_receipt" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"expenseId" varchar(255) NOT NULL,
|
||||||
|
"storageKey" varchar(500) NOT NULL,
|
||||||
|
"originalFilename" varchar(255) NOT NULL,
|
||||||
|
"mimeType" varchar(100) NOT NULL,
|
||||||
|
"sizeBytes" integer NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense_receipt"
|
||||||
|
ADD CONSTRAINT "beenvoice_expense_receipt_expenseId_beenvoice_expense_id_fk"
|
||||||
|
FOREIGN KEY ("expenseId") REFERENCES "public"."beenvoice_expense"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "expense_receipt_expense_id_idx" ON "beenvoice_expense_receipt" USING btree ("expenseId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_expense" e
|
||||||
|
SET "businessId" = i."businessId"
|
||||||
|
FROM "beenvoice_invoice" i
|
||||||
|
WHERE e."invoiceId" = i.id
|
||||||
|
AND e."businessId" IS NULL
|
||||||
|
AND i."businessId" IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_expense" e
|
||||||
|
SET "businessId" = sub.business_id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
e2.id AS expense_id,
|
||||||
|
(
|
||||||
|
SELECT b2.id
|
||||||
|
FROM "beenvoice_business" b2
|
||||||
|
WHERE b2."createdById" = e2."createdById"
|
||||||
|
ORDER BY b2."isDefault" DESC, b2."createdAt" DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS business_id
|
||||||
|
FROM "beenvoice_expense" e2
|
||||||
|
WHERE e2."businessId" IS NULL
|
||||||
|
) sub
|
||||||
|
WHERE e.id = sub.expense_id
|
||||||
|
AND sub.business_id IS NOT NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
|
||||||
|
# Rebuilds the app image from the current working tree, then starts/restarts services
|
||||||
|
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
|
||||||
|
# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}.
|
||||||
|
#
|
||||||
|
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
|
||||||
|
# changes from `git pull`. Always pass --build or use this script after pulling.
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${BEENVOICE_IMAGE:-}" ]] && command -v git >/dev/null 2>&1; then
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
BEENVOICE_IMAGE="beenvoice:$(git rev-parse --short HEAD)"
|
||||||
|
export BEENVOICE_IMAGE
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BEENVOICE_IMAGE="${BEENVOICE_IMAGE:-beenvoice:local}"
|
||||||
|
export BEENVOICE_IMAGE
|
||||||
|
|
||||||
|
echo "Deploying ${BEENVOICE_IMAGE} (docker compose up -d --build)..."
|
||||||
|
exec docker compose up -d --build "$@"
|
||||||
@@ -2,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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||||
|
import { getObject } from "~/lib/object-storage";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { expenseReceipts } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getOptionalServerSession(req.headers);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const receipt = await db.query.expenseReceipts.findFirst({
|
||||||
|
where: eq(expenseReceipts.id, id),
|
||||||
|
with: { expense: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (receipt?.expense.createdById !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await getObject(receipt.storageKey);
|
||||||
|
return new NextResponse(new Uint8Array(body), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": receipt.mimeType,
|
||||||
|
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
|
|||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
|
import { safeCallbackPath } from "~/lib/safe-callback-url";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface SignInFormProps {
|
interface SignInFormProps {
|
||||||
@@ -25,8 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
|
||||||
const signupDisabled = searchParams.get("signup") === "disabled";
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -40,10 +40,17 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
const message = error.message?.toLowerCase() ?? "";
|
||||||
|
const rateLimited =
|
||||||
|
error.status === 429 ||
|
||||||
|
message.includes("too many") ||
|
||||||
|
message.includes("rate limit");
|
||||||
toast.error(
|
toast.error(
|
||||||
error.message && error.message !== "Required"
|
rateLimited
|
||||||
? error.message
|
? "Too many sign-in attempts. Please wait a moment and try again."
|
||||||
: "Invalid email or password",
|
: error.message && error.message !== "Required"
|
||||||
|
? error.message
|
||||||
|
: "Invalid email or password",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,7 +81,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
description="Sign in to your workspace"
|
description="Sign in to your workspace"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{signupDisabled && (
|
{!allowRegistration && (
|
||||||
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
|
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
|
||||||
New account registration is currently disabled.
|
New account registration is currently disabled.
|
||||||
</p>
|
</p>
|
||||||
@@ -155,7 +162,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{allowRegistration && !signupDisabled && (
|
{allowRegistration && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don'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,84 +59,563 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export function AdministrationContent() {
|
const PAGE_SIZE = 25;
|
||||||
const {
|
|
||||||
data: accounts = [],
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
refetch,
|
"user.profile_updated": "Profile updated",
|
||||||
error,
|
"user.role_updated": "Role updated",
|
||||||
} = api.settings.listAccounts.useQuery();
|
"user.password_reset_sent": "Password reset sent",
|
||||||
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
"platform.pdf_settings_updated": "PDF settings updated",
|
||||||
onSuccess: () => {
|
};
|
||||||
toast.success("Account role updated");
|
|
||||||
void refetch();
|
function formatAction(action: string) {
|
||||||
},
|
return ACTION_LABELS[action] ?? action;
|
||||||
onError: (mutationError: { message: string }) => {
|
}
|
||||||
toast.error(`Failed to update role: ${mutationError.message}`);
|
|
||||||
},
|
function AdminOverview() {
|
||||||
});
|
const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle>Platform overview</CardTitle>
|
||||||
|
<CardDescription>Unable to load statistics.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: "Total users",
|
||||||
|
value: stats?.totalUsers ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Active (${stats?.activeUserWindowDays ?? 30}d)`,
|
||||||
|
value: stats?.activeUsers ?? 0,
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Administrators",
|
||||||
|
value: stats?.adminCount ?? 0,
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Invoices",
|
||||||
|
value: stats?.totalInvoices ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Businesses",
|
||||||
|
value: stats?.totalBusinesses ?? 0,
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clients",
|
||||||
|
value: stats?.totalClients ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Time entries",
|
||||||
|
value: stats?.totalTimeEntries ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="text-primary h-5 w-5" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
Administration
|
Platform overview
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Administrative access is required for this page.
|
Aggregate counts only — no customer data, credentials, or bulk PII.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading statistics…</p>
|
||||||
|
) : (
|
||||||
|
<div className={dashboardStatGridClass}>
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Card key={stat.label}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||||
|
<stat.icon className="h-3.5 w-3.5" />
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditUserState = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "user" | "admin";
|
||||||
|
};
|
||||||
|
|
||||||
|
function AdminUsers() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [editUser, setEditUser] = useState<EditUserState | null>(null);
|
||||||
|
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
|
const [resetUserName, setResetUserName] = useState("");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data, isLoading, error, isFetching } = api.admin.listUsers.useQuery({
|
||||||
|
search: deferredSearch || undefined,
|
||||||
|
offset,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = api.admin.updateUser.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("User updated");
|
||||||
|
setEditUser(null);
|
||||||
|
void utils.admin.listUsers.invalidate();
|
||||||
|
void utils.admin.listAuditLog.invalidate();
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error(mutationError.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendResetMutation = api.admin.sendPasswordReset.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.emailSent) {
|
||||||
|
toast.success("Password reset email sent");
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
"Reset token created, but email could not be sent. Check Resend configuration.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setResetUserId(null);
|
||||||
|
void utils.admin.listAuditLog.invalidate();
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error(mutationError.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>Administrative access is required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="text-primary h-5 w-5" />
|
||||||
|
Users
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search accounts, edit profiles, and manage access.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search by name or email…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading users…</p>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users className="h-6 w-6" />}
|
||||||
|
title="No users found"
|
||||||
|
description={
|
||||||
|
deferredSearch
|
||||||
|
? "Try a different search term."
|
||||||
|
: "No accounts have been created yet."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-border divide-y border">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{user.name}</p>
|
||||||
|
<Badge
|
||||||
|
variant={user.role === "admin" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
{user.emailVerified ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Joined{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setEditUser({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role as "user" | "admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setResetUserId(user.id);
|
||||||
|
setResetUserName(user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > PAGE_SIZE ? (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Page {currentPage} of {totalPages} · {total} users
|
||||||
|
{isFetching ? " · Updating…" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editUser != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit user</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editUser ? (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateUserMutation.mutate({
|
||||||
|
userId: editUser.id,
|
||||||
|
name: editUser.name,
|
||||||
|
email: editUser.email,
|
||||||
|
role: editUser.role,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-user-name"
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, name: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-user-email"
|
||||||
|
type="email"
|
||||||
|
value={editUser.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, email: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={editUser.role}
|
||||||
|
onValueChange={(role) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, role: role as "user" | "admin" }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="edit-user-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditUser(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateUserMutation.isPending}>
|
||||||
|
{updateUserMutation.isPending ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={resetUserId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setResetUserId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Send password reset?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
A password reset email will be sent to{" "}
|
||||||
|
<span className="font-medium">{resetUserName}</span>. The link
|
||||||
|
expires in 24 hours.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={sendResetMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (resetUserId) {
|
||||||
|
sendResetMutation.mutate({ userId: resetUserId });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sendResetMutation.isPending ? "Sending…" : "Send reset email"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminAuditLog() {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const { data, isLoading, error, isFetching } = api.admin.listAuditLog.useQuery(
|
||||||
|
{
|
||||||
|
offset,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit log</CardTitle>
|
||||||
|
<CardDescription>Administrative access is required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="text-primary h-5 w-5" />
|
<ScrollText className="text-primary h-5 w-5" />
|
||||||
Accounts
|
Audit log
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage account access and roles without opening customer data.
|
Recent administrative actions across the platform.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent>
|
||||||
{accounts.map((account) => (
|
{isLoading ? (
|
||||||
<div
|
<p className="text-muted-foreground text-sm">Loading audit log…</p>
|
||||||
key={account.id}
|
) : entries.length === 0 ? (
|
||||||
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
|
<EmptyState
|
||||||
>
|
icon={<ScrollText className="h-6 w-6" />}
|
||||||
<div className="min-w-0">
|
title="No audit events yet"
|
||||||
<p className="text-sm font-medium">{account.name}</p>
|
description="Administrative actions will appear here."
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
/>
|
||||||
{account.email}
|
) : (
|
||||||
</p>
|
<div className="divide-border divide-y border">
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
{entries.map((entry) => (
|
||||||
Created {new Date(account.createdAt).toLocaleDateString()}
|
<div key={entry.id} className="space-y-1 p-4">
|
||||||
</p>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<p className="text-sm font-medium">
|
||||||
<Select
|
{formatAction(entry.action)}
|
||||||
value={account.role}
|
</p>
|
||||||
onValueChange={(role) =>
|
<Badge variant="outline" className="text-xs">
|
||||||
updateAccountRoleMutation.mutate({
|
{entry.targetType}
|
||||||
userId: account.id,
|
</Badge>
|
||||||
role: role as "user" | "admin",
|
</div>
|
||||||
})
|
<p className="text-muted-foreground text-xs">
|
||||||
}
|
{entry.actor?.name ?? "Unknown admin"} ·{" "}
|
||||||
>
|
{new Date(entry.createdAt).toLocaleString(undefined, {
|
||||||
<SelectTrigger className="w-full sm:w-36">
|
month: "short",
|
||||||
<SelectValue />
|
day: "numeric",
|
||||||
</SelectTrigger>
|
year: "numeric",
|
||||||
<SelectContent>
|
hour: "numeric",
|
||||||
<SelectItem value="user">User</SelectItem>
|
minute: "2-digit",
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
})}
|
||||||
</SelectContent>
|
{entry.targetId ? ` · target ${entry.targetId.slice(0, 8)}…` : ""}
|
||||||
</Select>
|
</p>
|
||||||
|
{entry.metadata &&
|
||||||
|
Object.keys(entry.metadata).length > 0 ? (
|
||||||
|
<p className="text-muted-foreground font-mono text-xs break-all">
|
||||||
|
{JSON.stringify(entry.metadata)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{total > PAGE_SIZE ? (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Page {currentPage} of {totalPages} · {total} events
|
||||||
|
{isFetching ? " · Updating…" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AdministrationContent() {
|
||||||
|
return (
|
||||||
|
<PageTabs defaultValue="overview">
|
||||||
|
<PageTabsList>
|
||||||
|
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="users">Users</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="audit">Audit log</PageTabsTrigger>
|
||||||
|
</PageTabsList>
|
||||||
|
|
||||||
|
<PageTabsContent value="overview">
|
||||||
|
<AdminOverview />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent value="users">
|
||||||
|
<AdminUsers />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent value="audit">
|
||||||
|
<AdminAuditLog />
|
||||||
|
</PageTabsContent>
|
||||||
|
</PageTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+639
-243
@@ -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">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<Receipt className="h-5 w-5" /> All Expenses
|
<div>
|
||||||
</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> Expenses
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{filteredExpenses.length === expenses.length
|
||||||
|
? `${expenses.length} recorded`
|
||||||
|
: `${filteredExpenses.length} of ${expenses.length} shown`}
|
||||||
|
{filteredExpenses.length !== expenses.length
|
||||||
|
? ` · ${formatCurrency(visibleTotal)} visible`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative sm:w-64">
|
||||||
|
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search expenses"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||||
|
<SelectTrigger className="sm:w-52">
|
||||||
|
<SelectValue placeholder="All businesses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All businesses</SelectItem>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
["all", "All"] as const,
|
||||||
|
["billable", "Billable"] as const,
|
||||||
|
["deductible", "Deductible"] as const,
|
||||||
|
["receipts", "With receipts"] as const,
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
variant={expenseFilter === value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExpenseFilter(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -226,241 +402,461 @@ 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="divide-y">
|
<>
|
||||||
{expenses.map((expense) => (
|
<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">
|
||||||
<div
|
<span>Expense</span>
|
||||||
key={expense.id}
|
<span className="text-center">Receipts</span>
|
||||||
className="flex items-start justify-between gap-3 p-4"
|
<span className="text-right">Amount</span>
|
||||||
>
|
<span />
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="divide-y">
|
||||||
<p className="font-medium">{expense.description}</p>
|
{filteredExpenses.map((expense) => (
|
||||||
{expense.billable && (
|
<div
|
||||||
<Badge variant="secondary" className="text-xs">
|
key={expense.id}
|
||||||
Billable
|
role="button"
|
||||||
</Badge>
|
tabIndex={0}
|
||||||
)}
|
onClick={() => handleView(expense)}
|
||||||
{expense.reimbursable && (
|
onKeyDown={(e) => {
|
||||||
<Badge variant="outline" className="text-xs">
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
Reimbursable
|
e.preventDefault();
|
||||||
</Badge>
|
handleView(expense);
|
||||||
)}
|
}
|
||||||
{expense.taxDeductible && (
|
}}
|
||||||
<Badge
|
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"
|
||||||
variant="outline"
|
>
|
||||||
className="border-green-300 text-xs text-green-600"
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Tax Deductible
|
<p className="font-medium">{expense.description}</p>
|
||||||
</Badge>
|
{expense.billable && (
|
||||||
)}
|
<Badge variant="secondary" className="text-xs">
|
||||||
{expense.category && (
|
Billable
|
||||||
<Badge variant="outline" className="text-xs">
|
</Badge>
|
||||||
{expense.category}
|
)}
|
||||||
</Badge>
|
{expense.reimbursable && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Reimbursable
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{expense.taxDeductible && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-300 text-xs text-green-600"
|
||||||
|
>
|
||||||
|
Tax Deductible
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{expense.category && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{expense.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(expense.date))}
|
||||||
|
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||||
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
|
</p>
|
||||||
|
{expense.notes && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{expense.notes}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
<div
|
||||||
month: "short",
|
className="flex items-center sm:justify-center"
|
||||||
day: "numeric",
|
onClick={(e) => e.stopPropagation()}
|
||||||
year: "numeric",
|
>
|
||||||
}).format(new Date(expense.date))}
|
<span className="text-muted-foreground mr-2 text-xs sm:hidden">
|
||||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
Receipts
|
||||||
</p>
|
</span>
|
||||||
{expense.notes && (
|
<ExpenseReceiptIndicator
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
expenseId={expense.id}
|
||||||
{expense.notes}
|
receiptCount={expense.receiptCount}
|
||||||
</p>
|
receiptPreview={expense.receiptPreview}
|
||||||
)}
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
|
||||||
<p className="font-semibold">
|
<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"
|
||||||
onClick={() => setDeleteId(expense.id)}
|
aria-label={`Actions for ${expense.description}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleView(expense)}>
|
||||||
|
<Receipt className="mr-2 h-4 w-4" />
|
||||||
|
View details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(expense)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteId(expense.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
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">
|
||||||
<div className="space-y-2">
|
{isViewMode ? (
|
||||||
<Label>Description *</Label>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Input
|
<div className="space-y-1 sm:col-span-2">
|
||||||
value={form.description}
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
onChange={(e) =>
|
Description
|
||||||
setForm((p) => ({ ...p, description: e.target.value }))
|
</p>
|
||||||
}
|
<p className="text-sm">{form.description}</p>
|
||||||
placeholder="e.g. Laptop charger"
|
</div>
|
||||||
/>
|
<div className="space-y-1">
|
||||||
</div>
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
Amount
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<Label>Amount *</Label>
|
<p className="text-sm font-semibold">
|
||||||
<NumberInput
|
{formatCurrency(form.amount, form.currency)}
|
||||||
value={form.amount}
|
</p>
|
||||||
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
|
</div>
|
||||||
min={0}
|
<div className="space-y-1">
|
||||||
step={0.01}
|
<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>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
<Label>Currency</Label>
|
<>
|
||||||
<Select
|
<div className="space-y-2">
|
||||||
value={form.currency}
|
<Label>Description *</Label>
|
||||||
onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}
|
<Input
|
||||||
>
|
value={form.description}
|
||||||
<SelectTrigger>
|
onChange={(e) =>
|
||||||
<SelectValue />
|
setForm((p) => ({ ...p, description: e.target.value }))
|
||||||
</SelectTrigger>
|
}
|
||||||
<SelectContent>
|
placeholder="e.g. Laptop charger"
|
||||||
{SUPPORTED_CURRENCIES.map((c) => (
|
/>
|
||||||
<SelectItem key={c.code} value={c.code}>
|
</div>
|
||||||
{c.code}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
</SelectItem>
|
<div className="space-y-2">
|
||||||
))}
|
<Label>Amount *</Label>
|
||||||
</SelectContent>
|
<NumberInput
|
||||||
</Select>
|
value={form.amount}
|
||||||
</div>
|
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
|
||||||
</div>
|
min={0}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
step={0.01}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<Label>Date</Label>
|
</div>
|
||||||
<DatePicker
|
<div className="space-y-2">
|
||||||
date={form.date}
|
<Label>Currency</Label>
|
||||||
onDateChange={(d) =>
|
<Select
|
||||||
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
value={form.currency}
|
||||||
}
|
onValueChange={(v) =>
|
||||||
className="w-full"
|
setForm((p) => ({ ...p, currency: v }))
|
||||||
/>
|
}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-2">
|
<SelectTrigger>
|
||||||
<Label>Category</Label>
|
<SelectValue />
|
||||||
<Select
|
</SelectTrigger>
|
||||||
value={form.category || "none"}
|
<SelectContent>
|
||||||
onValueChange={(v) =>
|
{SUPPORTED_CURRENCIES.map((c) => (
|
||||||
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
|
<SelectItem key={c.code} value={c.code}>
|
||||||
}
|
{c.code}
|
||||||
>
|
</SelectItem>
|
||||||
<SelectTrigger>
|
))}
|
||||||
<SelectValue placeholder="Select…" />
|
</SelectContent>
|
||||||
</SelectTrigger>
|
</Select>
|
||||||
<SelectContent>
|
</div>
|
||||||
<SelectItem value="none">None</SelectItem>
|
</div>
|
||||||
{EXPENSE_CATEGORIES.map((c) => (
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<SelectItem key={c} value={c}>
|
<div className="space-y-2">
|
||||||
{c}
|
<Label>Date</Label>
|
||||||
</SelectItem>
|
<DatePicker
|
||||||
))}
|
date={form.date}
|
||||||
</SelectContent>
|
onDateChange={(d) =>
|
||||||
</Select>
|
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
||||||
</div>
|
}
|
||||||
</div>
|
className="w-full"
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<Label>Client (optional)</Label>
|
</div>
|
||||||
<Select
|
<div className="space-y-2">
|
||||||
value={form.clientId || "none"}
|
<Label>Category</Label>
|
||||||
onValueChange={(v) =>
|
<Select
|
||||||
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
|
value={form.category || "none"}
|
||||||
}
|
onValueChange={(v) =>
|
||||||
>
|
setForm((p) => ({
|
||||||
<SelectTrigger>
|
...p,
|
||||||
<SelectValue placeholder="No client" />
|
category: v === "none" ? "" : v,
|
||||||
</SelectTrigger>
|
}))
|
||||||
<SelectContent>
|
}
|
||||||
<SelectItem value="none">No client</SelectItem>
|
>
|
||||||
{clients.map((c) => (
|
<SelectTrigger>
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectValue placeholder="Select…" />
|
||||||
{c.name}
|
</SelectTrigger>
|
||||||
</SelectItem>
|
<SelectContent>
|
||||||
))}
|
<SelectItem value="none">None</SelectItem>
|
||||||
</SelectContent>
|
{EXPENSE_CATEGORIES.map((c) => (
|
||||||
</Select>
|
<SelectItem key={c} value={c}>
|
||||||
</div>
|
{c}
|
||||||
<div className="flex flex-wrap gap-6">
|
</SelectItem>
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
))}
|
||||||
<Checkbox
|
</SelectContent>
|
||||||
checked={form.billable}
|
</Select>
|
||||||
onCheckedChange={(v) =>
|
</div>
|
||||||
setForm((p) => ({ ...p, billable: !!v }))
|
</div>
|
||||||
}
|
<div className="space-y-2">
|
||||||
/>
|
<Label>Business</Label>
|
||||||
<span className="text-sm">Billable</span>
|
<Select
|
||||||
</label>
|
value={form.businessId || "none"}
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
onValueChange={(v) =>
|
||||||
<Checkbox
|
setForm((p) => ({
|
||||||
checked={form.reimbursable}
|
...p,
|
||||||
onCheckedChange={(v) =>
|
businessId: v === "none" ? "" : v,
|
||||||
setForm((p) => ({ ...p, reimbursable: !!v }))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<span className="text-sm">Reimbursable</span>
|
<SelectTrigger>
|
||||||
</label>
|
<SelectValue placeholder="Select business" />
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
</SelectTrigger>
|
||||||
<Checkbox
|
<SelectContent>
|
||||||
checked={form.taxDeductible}
|
<SelectItem value="none">Default business</SelectItem>
|
||||||
onCheckedChange={(v) =>
|
{businesses.map((b) => (
|
||||||
setForm((p) => ({ ...p, taxDeductible: !!v }))
|
<SelectItem key={b.id} value={b.id}>
|
||||||
}
|
{b.name}
|
||||||
/>
|
{b.isDefault ? " (default)" : ""}
|
||||||
<span className="text-sm">Tax Deductible</span>
|
</SelectItem>
|
||||||
</label>
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
<div className="space-y-2">
|
</Select>
|
||||||
<Label>Notes (optional)</Label>
|
</div>
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
value={form.notes}
|
<Label>Client (optional)</Label>
|
||||||
onChange={(e) =>
|
<Select
|
||||||
setForm((p) => ({ ...p, notes: e.target.value }))
|
value={form.clientId || "none"}
|
||||||
}
|
onValueChange={(v) =>
|
||||||
placeholder="Additional details…"
|
setForm((p) => ({
|
||||||
/>
|
...p,
|
||||||
</div>
|
clientId: v === "none" ? "" : v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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="flex flex-wrap gap-6">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.billable}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, billable: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Billable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.reimbursable}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, reimbursable: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Reimbursable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.taxDeductible}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, taxDeductible: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Tax Deductible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, notes: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Additional details…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:gap-3">
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
{isViewMode ? (
|
||||||
Cancel
|
<>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
onClick={handleSubmit}
|
className="w-full sm:w-auto"
|
||||||
disabled={create.isPending || update.isPending}
|
onClick={closeDialog}
|
||||||
>
|
>
|
||||||
{create.isPending || update.isPending
|
Close
|
||||||
? "Saving…"
|
</Button>
|
||||||
: editId
|
<Button
|
||||||
? "Update"
|
className="w-full sm:w-auto"
|
||||||
: "Add Expense"}
|
onClick={() => setDialogMode("edit")}
|
||||||
</Button>
|
>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={closeDialog}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={create.isPending || update.isPending}
|
||||||
|
>
|
||||||
|
{create.isPending || update.isPending
|
||||||
|
? "Saving…"
|
||||||
|
: isEditMode
|
||||||
|
? "Update"
|
||||||
|
: "Save & add receipts"}
|
||||||
|
</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)} · {item.hours}h @{" "}
|
{formatDate(item.date)} ·{" "}
|
||||||
{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,
|
||||||
|
|||||||
@@ -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,"Web development work",8,75.00,600.00
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
|
||||||
<div className="bg-muted/50 p-3">
|
|
||||||
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Important Notes Section
|
|
||||||
function ImportantNotes() {
|
|
||||||
return (
|
|
||||||
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircle className="text-primary h-5 w-5" />
|
|
||||||
Important Notes
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 font-semibold">Before Importing:</h4>
|
|
||||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<li>• Use M/DD/YY format for dates (e.g., 1/15/24)</li>
|
|
||||||
<li>• Ensure rates are in decimal format (e.g., 75.50)</li>
|
|
||||||
<li>• File names should follow YYYY-MM-DD.csv format</li>
|
|
||||||
<li>• Select a client before importing</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 font-semibold">What Happens:</h4>
|
|
||||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<li>• Each CSV file creates one invoice</li>
|
|
||||||
<li>• Invoice dates are derived from filename</li>
|
|
||||||
<li>• Invoices are created in "draft" status</li>
|
|
||||||
<li>• You can review and edit before sending</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// File Format Help Section
|
|
||||||
function FileFormatHelp() {
|
|
||||||
return (
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
|
||||||
<FileSpreadsheet className="text-primary h-5 w-5" />
|
|
||||||
Supported File Formats
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<div className="bg-accent mx-auto w-fit p-3">
|
|
||||||
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold">CSV Files</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Comma-separated values from Excel, Google Sheets, or any CSV
|
|
||||||
editor
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
|
||||||
<Upload className="text-primary h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold">Max Size</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Up to 10MB per file with no limit on number of rows
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<div className="bg-secondary mx-auto w-fit p-3">
|
|
||||||
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold">Validation</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Real-time validation with clear error messages and feedback
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ImportPage() {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,72 +14,86 @@ 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
|
|
||||||
className={cn(
|
|
||||||
"h-0.5 flex-1 rounded-full transition-colors",
|
|
||||||
connectorComplete || isCurrent
|
|
||||||
? "bg-primary"
|
|
||||||
: "bg-border/80",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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",
|
"h-0.5 self-center rounded-full transition-colors",
|
||||||
isComplete &&
|
connectorComplete ? "bg-primary" : "bg-border/80",
|
||||||
"border-primary bg-primary text-primary-foreground",
|
|
||||||
isCurrent &&
|
|
||||||
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
|
|
||||||
isUpcoming &&
|
|
||||||
"border-border/80 bg-background/60 text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
aria-current={isCurrent ? "step" : undefined}
|
style={{ gridColumn: index * 2, gridRow: 1 }}
|
||||||
>
|
/>
|
||||||
{isComplete ? (
|
)}
|
||||||
<Check className="h-4 w-4" aria-hidden />
|
<div
|
||||||
) : (
|
className={cn(
|
||||||
<span>{index + 1}</span>
|
"flex h-9 w-9 items-center justify-center justify-self-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||||
)}
|
isComplete &&
|
||||||
</div>
|
"border-primary bg-primary text-primary-foreground",
|
||||||
{index < ONBOARDING_STEPS.length - 1 && (
|
isCurrent &&
|
||||||
<div
|
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
|
||||||
className={cn(
|
isUpcoming &&
|
||||||
"h-0.5 flex-1 rounded-full transition-colors",
|
"border-border/80 bg-background/60 text-muted-foreground",
|
||||||
connectorComplete ? "bg-primary" : "bg-border/80",
|
)}
|
||||||
)}
|
style={{ gridColumn: circleCol, gridRow: 1 }}
|
||||||
aria-hidden
|
>
|
||||||
/>
|
{isComplete ? (
|
||||||
|
<Check className="h-4 w-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}
|
||||||
|
|||||||
@@ -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 "acme-january"). Column headers are flexible
|
||||||
|
and auto-detected from the .csv extension.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-muted border-border rounded-md border p-3">
|
||||||
|
<p className="text-foreground font-mono text-sm">
|
||||||
|
date,description,quantity,rate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">
|
||||||
|
Columns (header row required)
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{CSV_COLUMNS.map((col) => (
|
||||||
|
<div key={col.field} className="flex items-start gap-3">
|
||||||
|
<Badge className="border font-mono text-xs">
|
||||||
|
{col.field}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{col.desc}
|
||||||
|
{col.required === true && " — required"}
|
||||||
|
{typeof col.required === "string" &&
|
||||||
|
` — ${col.required} required`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Example rows</h4>
|
||||||
|
<div className="bg-muted border-border space-y-2 rounded-md border p-3">
|
||||||
|
<p className="text-foreground font-mono text-xs break-all">
|
||||||
|
2024-01-15,"API development",8,125.00
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground font-mono text-xs break-all">
|
||||||
|
1/16/24,Design review,2,125.00
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Rules</h4>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
• Column names are case-insensitive. Legacy columns{" "}
|
||||||
|
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||||
|
HOURS
|
||||||
|
</code>{" "}
|
||||||
|
and{" "}
|
||||||
|
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||||
|
DATE
|
||||||
|
</code>{" "}
|
||||||
|
are still supported.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• Select a default client in Settings → Data before uploading
|
||||||
|
CSV files.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• Each line item needs a description (or item), quantity, and
|
||||||
|
rate.
|
||||||
|
</li>
|
||||||
|
<li>• Max 10 MB per file, up to 50 files at once.</li>
|
||||||
|
<li>
|
||||||
|
• Preview staged invoices and fix per-row errors before you
|
||||||
|
commit the import.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImportCsvTemplateButton />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent
|
||||||
|
value="json"
|
||||||
|
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Import one or many invoices from a single JSON file. Clients are
|
||||||
|
matched by email, then name, or created automatically when
|
||||||
|
details are provided.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Example</h4>
|
||||||
|
<div className="bg-muted border-border max-h-64 overflow-auto rounded-md border p-3">
|
||||||
|
<pre className="text-foreground font-mono text-xs whitespace-pre-wrap">
|
||||||
|
{JSON_TEMPLATE}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Rules</h4>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
• Root may be{" "}
|
||||||
|
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||||
|
{"{ invoices: [...] }"}
|
||||||
|
</code>
|
||||||
|
, an array, or a single invoice object.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• Line items use{" "}
|
||||||
|
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||||
|
quantity
|
||||||
|
</code>{" "}
|
||||||
|
or{" "}
|
||||||
|
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||||
|
hours
|
||||||
|
</code>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• Issue and due dates default from item dates (+30 days for
|
||||||
|
due).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• New clients are created when JSON includes unknown client
|
||||||
|
details.
|
||||||
|
</li>
|
||||||
|
<li>• Max 10 MB per file, up to 50 files at once.</li>
|
||||||
|
<li>
|
||||||
|
• Partial success: valid invoices import; errors are reported
|
||||||
|
per row.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImportJsonTemplateButton />
|
||||||
|
</PageTabsContent>
|
||||||
|
</PageTabs>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ImportFormatInfoDialog } from "./import-format-info-dialog";
|
||||||
|
|
||||||
|
export function ImportPageHeaderActions() {
|
||||||
|
return <ImportFormatInfoDialog />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileJson, FileSpreadsheet } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
downloadCsvTemplate,
|
||||||
|
downloadJsonTemplate,
|
||||||
|
} from "~/lib/invoice-import-templates";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function ImportCsvTemplateButton({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn("hover-lift shadow-sm", className)}
|
||||||
|
onClick={downloadCsvTemplate}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="mr-2 h-5 w-5" />
|
||||||
|
Download CSV template
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportJsonTemplateButton({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn("hover-lift shadow-sm", className)}
|
||||||
|
onClick={downloadJsonTemplate}
|
||||||
|
>
|
||||||
|
<FileJson className="mr-2 h-5 w-5" />
|
||||||
|
Download JSON template
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportTemplateButtons({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<ImportCsvTemplateButton />
|
||||||
|
<ImportJsonTemplateButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
“{receipt.originalFilename}” will be permanently
|
||||||
|
removed. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteReceipt.isPending}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={deleteReceipt.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteReceipt.mutate({ id: receipt.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Loader2, Paperclip } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { FileUpload } from "~/components/forms/file-upload";
|
||||||
|
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||||
|
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
import {
|
||||||
|
fileToBase64,
|
||||||
|
RECEIPT_ACCEPT,
|
||||||
|
RECEIPT_MAX_SIZE,
|
||||||
|
RECEIPT_UPLOAD_HINT,
|
||||||
|
} from "~/components/expenses/receipt-utils";
|
||||||
|
|
||||||
|
interface ExpenseReceiptsPanelProps {
|
||||||
|
expenseId: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseReceiptsPanel({
|
||||||
|
expenseId,
|
||||||
|
readOnly = false,
|
||||||
|
}: ExpenseReceiptsPanelProps) {
|
||||||
|
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [uploadKey, setUploadKey] = useState(0);
|
||||||
|
const processedFileCountRef = useRef(0);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||||
|
{ expenseId: expenseId! },
|
||||||
|
{ enabled: !!expenseId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
if (expenseId) {
|
||||||
|
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||||
|
void utils.expenses.getAll.invalidate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFiles = async (files: File[]) => {
|
||||||
|
if (!expenseId || files.length === 0) return;
|
||||||
|
|
||||||
|
const newFiles = files.slice(processedFileCountRef.current);
|
||||||
|
processedFileCountRef.current = files.length;
|
||||||
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
for (const file of newFiles) {
|
||||||
|
try {
|
||||||
|
const data = await fileToBase64(file);
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
uploaded++;
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Upload failed";
|
||||||
|
toast.error(`${file.name}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploaded > 0) {
|
||||||
|
toast.success(
|
||||||
|
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
|
||||||
|
);
|
||||||
|
processedFileCountRef.current = 0;
|
||||||
|
setUploadKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!expenseId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
Receipts
|
||||||
|
</Label>
|
||||||
|
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||||
|
Save the expense first, then drag and drop receipts here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
Receipts
|
||||||
|
{receipts.length > 0 && (
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
({receipts.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{uploadReceipt.isPending && (
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Uploading…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading receipts…
|
||||||
|
</div>
|
||||||
|
) : receipts.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||||
|
{readOnly
|
||||||
|
? "No receipts attached."
|
||||||
|
: "No receipts yet. Drop images or PDFs below."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<ExpenseReceiptItem
|
||||||
|
key={receipt.id}
|
||||||
|
receipt={receipt}
|
||||||
|
expenseId={expenseId}
|
||||||
|
onView={setViewerReceipt}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<FileUpload
|
||||||
|
key={`${expenseId}-${uploadKey}`}
|
||||||
|
onFilesSelected={(files) => void handleFiles(files)}
|
||||||
|
accept={RECEIPT_ACCEPT}
|
||||||
|
maxFiles={5}
|
||||||
|
maxSize={RECEIPT_MAX_SIZE}
|
||||||
|
disabled={uploadReceipt.isPending}
|
||||||
|
placeholder="Drop receipts here or tap to browse"
|
||||||
|
description={RECEIPT_UPLOAD_HINT}
|
||||||
|
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReceiptViewerDialog
|
||||||
|
receipt={viewerReceipt}
|
||||||
|
open={!!viewerReceipt}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setViewerReceipt(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const RECEIPT_ACCEPT: Record<string, string[]> = {
|
||||||
|
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
||||||
|
"application/pdf": [".pdf"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const RECEIPT_UPLOAD_HINT =
|
||||||
|
"PNG, JPG, or PDF · up to 10MB each";
|
||||||
|
|
||||||
|
export function receiptUrl(receiptId: string) {
|
||||||
|
return `/api/receipts/${receiptId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageReceipt(mimeType: string) {
|
||||||
|
return mimeType.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfReceipt(mimeType: string) {
|
||||||
|
return mimeType === "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReceiptSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(",")[1];
|
||||||
|
if (!base64) {
|
||||||
|
reject(new Error("Failed to read file"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = () =>
|
||||||
|
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink, FileText } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
isImageReceipt,
|
||||||
|
isPdfReceipt,
|
||||||
|
receiptUrl,
|
||||||
|
} from "~/components/expenses/receipt-utils";
|
||||||
|
|
||||||
|
export interface ReceiptViewerTarget {
|
||||||
|
id: string;
|
||||||
|
originalFilename: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiptViewerDialogProps {
|
||||||
|
receipt: ReceiptViewerTarget | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReceiptViewerDialog({
|
||||||
|
receipt,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ReceiptViewerDialogProps) {
|
||||||
|
if (!receipt) return null;
|
||||||
|
|
||||||
|
const url = receiptUrl(receipt.id);
|
||||||
|
const isImage = isImageReceipt(receipt.mimeType);
|
||||||
|
const isPdf = isPdfReceipt(receipt.mimeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="truncate pr-8">
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
|
||||||
|
{isImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={receipt.originalFilename}
|
||||||
|
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
title={receipt.originalFilename}
|
||||||
|
className="h-[min(70vh,720px)] w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
|
||||||
|
<FileText className="h-10 w-10" />
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open file
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 sm:justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open in new tab
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -266,9 +274,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
items: formData.items.map((item) => ({
|
items: formData.items.map((item) => ({
|
||||||
date: item.date,
|
date: item.date,
|
||||||
description: item.description || "Service",
|
description: item.description || "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
})),
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
[formData],
|
[formData],
|
||||||
);
|
);
|
||||||
@@ -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;
|
||||||
const updated = { ...item, [field]: value };
|
|
||||||
if (field === "hours" || field === "rate") {
|
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
||||||
updated.amount = updated.hours * updated.rate;
|
const next = applyBillingTypeChange(value, item);
|
||||||
}
|
return {
|
||||||
return updated;
|
...item,
|
||||||
|
...next,
|
||||||
|
billingType: value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return item;
|
|
||||||
|
const updated = { ...item, [field]: value };
|
||||||
|
if (field === "hours" || field === "rate") {
|
||||||
|
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
|
||||||
|
updated.billingType = getLineItemBillingType(updated.hours);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -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),
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,16 +198,36 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<Select
|
||||||
value={item.hours}
|
value={billingType}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onValueChange={(value: LineItemBillingType) =>
|
||||||
min={0}
|
onUpdate(index, "billingType", value)
|
||||||
step={0.25}
|
}
|
||||||
width="full"
|
|
||||||
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
|
||||||
suffix="h"
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isFixed ? (
|
||||||
|
<span className="text-muted-foreground text-center text-xs">—</span>
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
width="full"
|
||||||
|
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||||
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${lineTotal.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -235,6 +276,10 @@ function MobileLineItem({
|
|||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
|
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||||
|
const isFixed = billingType === "fixed";
|
||||||
|
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`invoice-item-${index}-mobile`}
|
id={`invoice-item-${index}-mobile`}
|
||||||
@@ -264,16 +309,33 @@ function MobileLineItem({
|
|||||||
inputClassName="h-8 px-2 text-xs"
|
inputClassName="h-8 px-2 text-xs"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<Select
|
||||||
value={item.hours}
|
value={billingType}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onValueChange={(value: LineItemBillingType) =>
|
||||||
min={0}
|
onUpdate(index, "billingType", value)
|
||||||
step={0.25}
|
}
|
||||||
width="full"
|
|
||||||
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
|
|
||||||
suffix="h"
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!isFixed ? (
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
width="full"
|
||||||
|
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
|
||||||
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
onChange={(value) => onUpdate(index, "rate", value)}
|
onChange={(value) => onUpdate(index, "rate", value)}
|
||||||
@@ -285,7 +347,7 @@ function MobileLineItem({
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${lineTotal.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
|
|||||||
onUpdateItem(index, "description", s.description);
|
onUpdateItem(index, "description", s.description);
|
||||||
onUpdateItem(index, "hours", s.hours);
|
onUpdateItem(index, "hours", s.hours);
|
||||||
onUpdateItem(index, "rate", s.rate);
|
onUpdateItem(index, "rate", s.rate);
|
||||||
|
onUpdateItem(index, "billingType", "hourly");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setQueriedIndex(null);
|
setQueriedIndex(null);
|
||||||
}
|
}
|
||||||
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
|
|||||||
) : null}
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[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,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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,19 +565,99 @@ export function TimeClockPanel({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
<RunningTextFields
|
||||||
<Input
|
key={running.id}
|
||||||
id="clock-stop-note"
|
running={running}
|
||||||
value={stopNote}
|
updateRunningPending={updateRunning.isPending}
|
||||||
onChange={(e) => setStopNote(e.target.value)}
|
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||||
placeholder={
|
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||||
running?.description?.trim()
|
|
||||||
? running.description
|
|
||||||
: "Update description when you stop"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<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">
|
||||||
|
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="clock-stop-note"
|
||||||
|
value={stopNote}
|
||||||
|
onChange={(e) => setStopNote(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
running?.description?.trim()
|
||||||
|
? running.description
|
||||||
|
: "Update description when you stop"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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's entries</CardTitle>
|
<CardTitle className="text-base">Today'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.{" "}
|
||||||
);
|
<Link
|
||||||
const content = (
|
href="/dashboard/time-clock/entries"
|
||||||
<>
|
className="text-primary hover:underline"
|
||||||
<div className="min-w-0">
|
>
|
||||||
<p className="font-medium">
|
View history
|
||||||
{formatRunningTimerLabel(entry.description)}
|
</Link>
|
||||||
</p>
|
</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
|
|
||||||
key={entry.id}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/");
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,14 +42,159 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const expensesRouter = createTRPCRouter({
|
async function verifyClientAccess(
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||||
return await ctx.db.query.expenses.findMany({
|
clientId: string,
|
||||||
where: eq(expenses.createdById, ctx.session.user.id),
|
) {
|
||||||
with: { client: true, business: true, invoice: true },
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
orderBy: [desc(expenses.date)],
|
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({
|
||||||
|
getAll: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.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)],
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
date: z.date(),
|
.object({
|
||||||
description: z.string().min(1, "Description is required"),
|
date: z.date(),
|
||||||
hours: z.number().min(0, "Hours must be positive"),
|
description: z.string().min(1, "Description is required"),
|
||||||
rate: z.number().min(0, "Rate must be positive"),
|
hours: z.number().min(0, "Hours 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
Reference in New Issue
Block a user