Compare commits
21
Commits
6ec26a4a0d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3b1362a9 | ||
|
|
40624f6e6f | ||
|
|
d589b94580 | ||
|
|
da219b287e | ||
|
|
316edb4b3e | ||
|
|
8866335c50 | ||
|
|
fb731885de | ||
|
|
1605322bc9 | ||
|
|
ef826dffa9 | ||
|
|
d0c916d659 | ||
|
|
a66d05f24f | ||
|
|
122a3c64f5 | ||
|
|
31151e7f39 | ||
|
|
c267b3e1c8 | ||
|
|
5e6e51337a | ||
|
|
b9a9b813d2 | ||
|
|
85df7c4627 | ||
|
|
6b73c32c25 | ||
|
|
5978c8d903 | ||
|
|
3fb61ff4dd | ||
|
|
c53f2e6c4d |
+42
-21
@@ -4,14 +4,15 @@
|
|||||||
#
|
#
|
||||||
# Quick start (local dev):
|
# Quick start (local dev):
|
||||||
# cp .env.example .env.local
|
# cp .env.example .env.local
|
||||||
# docker compose -f docker-compose.dev.yml up -d
|
# docker compose -f docker-compose.dev.yml up -d # Postgres + Garage
|
||||||
# bun run db:push # or: bun run db:migrate
|
# bun run db:push # or: bun run db:migrate
|
||||||
# bun run dev
|
# bun run dev
|
||||||
|
# Garage S3 API: http://localhost:3900
|
||||||
#
|
#
|
||||||
# Quick start (Docker app + Postgres):
|
# Quick start (Docker app + Postgres):
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
# # edit AUTH_SECRET + public URLs below
|
# # edit AUTH_SECRET + public URLs below
|
||||||
# docker compose up -d --build
|
# ./scripts/docker-deploy.sh
|
||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Build-time vs runtime (Docker)
|
# Build-time vs runtime (Docker)
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
# emails, and MCP links. In the browser, sign-in uses the current page origin
|
# emails, and MCP links. In the browser, sign-in uses the current page origin
|
||||||
# automatically so dev works when Next picks another port (e.g. 3002).
|
# automatically so dev works when Next picks another port (e.g. 3002).
|
||||||
#
|
#
|
||||||
# Updating production: git pull && docker compose up -d --build
|
# Updating production: git pull && ./scripts/docker-deploy.sh
|
||||||
|
# (or: docker compose up -d --build). Plain `docker compose up -d` does NOT rebuild.
|
||||||
# Migrations run on every app start (idempotent — only pending SQL is applied).
|
# Migrations run on every app start (idempotent — only pending SQL is applied).
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -62,8 +64,9 @@ NODE_ENV=development
|
|||||||
# Set true when connecting to local Postgres without SSL (default for compose).
|
# Set true when connecting to local Postgres without SSL (default for compose).
|
||||||
DB_DISABLE_SSL=true
|
DB_DISABLE_SSL=true
|
||||||
|
|
||||||
# Dev-only: host port for `docker compose -f docker-compose.dev.yml` Postgres.
|
# Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
GARAGE_API_PORT=3900
|
||||||
|
|
||||||
# Optional: if Next dev picks another port, you do not need to change URLs for
|
# Optional: if Next dev picks another port, you do not need to change URLs for
|
||||||
# sign-in — the auth client uses window.location.origin in the browser.
|
# sign-in — the auth client uses window.location.origin in the browser.
|
||||||
@@ -75,6 +78,10 @@ POSTGRES_PORT=5432
|
|||||||
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
|
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
|
|
||||||
|
# App image tag for docker-compose.yml (optional). docker-deploy.sh sets
|
||||||
|
# beenvoice:<git-sha> automatically; default without it is beenvoice:local.
|
||||||
|
# BEENVOICE_IMAGE=beenvoice:local
|
||||||
|
|
||||||
# Postgres credentials for docker-compose.yml `db` service.
|
# Postgres credentials for docker-compose.yml `db` service.
|
||||||
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
|
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
@@ -85,27 +92,13 @@ POSTGRES_DB=postgres
|
|||||||
# White-label defaults (optional)
|
# White-label defaults (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Baked in at Docker build. After first deploy, admins can override many of
|
# Baked in at Docker build. After first deploy, admins can override many of
|
||||||
# these from Settings → Appearance in the dashboard.
|
# Optional white-label defaults (build-time). Users choose light/dark in Settings.
|
||||||
|
|
||||||
NEXT_PUBLIC_BRAND_NAME=beenvoice
|
NEXT_PUBLIC_BRAND_NAME=beenvoice
|
||||||
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
||||||
NEXT_PUBLIC_BRAND_ICON=$
|
NEXT_PUBLIC_BRAND_ICON=$
|
||||||
|
|
||||||
# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
|
|
||||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
|
|
||||||
|
|
||||||
# Font prefs: brand | frutiger | platform | inter | serif
|
|
||||||
NEXT_PUBLIC_DEFAULT_FONT=brand
|
|
||||||
NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
|
|
||||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
|
|
||||||
|
|
||||||
# Corner radius: none | sm | md | lg | xl
|
|
||||||
NEXT_PUBLIC_DEFAULT_RADIUS=xl
|
|
||||||
|
|
||||||
# Sidebar chrome: floating | docked
|
|
||||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Email — Resend (optional)
|
# Email — Resend (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -126,12 +119,40 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
|||||||
# Access control (optional)
|
# Access control (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Block new email/password registrations. Use literal true or false.
|
# Block new email/password registrations (default: true / signups off).
|
||||||
# DISABLE_SIGNUPS=true
|
# Set DISABLE_SIGNUPS=false to allow new email/password signups.
|
||||||
|
# DISABLE_SIGNUPS=false
|
||||||
|
|
||||||
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
|
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
|
||||||
# CRON_SECRET=
|
# CRON_SECRET=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Receipt storage — S3-compatible (optional)
|
||||||
|
# =============================================================================
|
||||||
|
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
|
||||||
|
# .data/receipts/ (dev-friendly). Works with AWS S3, Garage, Cloudflare R2, etc.
|
||||||
|
#
|
||||||
|
# S3_ENDPOINT — who can reach Garage?
|
||||||
|
# • Host dev (bun dev + docker-compose.dev.yml Garage on the host): localhost:3900
|
||||||
|
# • App in Docker (docker-compose.yml): http://garage:3900 (Compose service name)
|
||||||
|
# • Coolify — see docs/COOLIFY.md. Summary:
|
||||||
|
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
|
||||||
|
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
|
||||||
|
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900
|
||||||
|
# with Connect to Predefined Network on both resources. Never bare "garage".
|
||||||
|
# - NEVER use localhost in production — inside the app container that is the app, not Garage.
|
||||||
|
#
|
||||||
|
# Local dev with docker-compose.dev.yml Garage (host `bun dev`):
|
||||||
|
S3_ENDPOINT=http://localhost:3900
|
||||||
|
S3_BUCKET=beenvoice-receipts
|
||||||
|
S3_ACCESS_KEY=GK3515373e4c851ebaad366558
|
||||||
|
S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
|
||||||
|
S3_REGION=garage
|
||||||
|
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
|
||||||
|
#
|
||||||
|
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
|
||||||
|
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SSO — Authentik OIDC (optional)
|
# SSO — Authentik OIDC (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432):
|
|||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
|
||||||
|
|
||||||
Apply schema (pick one):
|
Apply schema (pick one):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -80,13 +82,20 @@ bun run db:push # fast iteration during development
|
|||||||
# bun run db:migrate # same migrations the Docker image runs in production
|
# bun run db:migrate # same migrations the Docker image runs in production
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`:
|
||||||
|
|
||||||
|
- Email: `demo@example.com`
|
||||||
|
- Password: `demo123`
|
||||||
|
|
||||||
|
The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, then sign in.
|
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, or sign in with the demo account above.
|
||||||
|
|
||||||
## Docker deployment (app + database)
|
## Docker deployment (app + database)
|
||||||
|
|
||||||
@@ -123,29 +132,50 @@ docker compose build --no-cache app
|
|||||||
### 2. First start (or after code changes)
|
### 2. First start (or after code changes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
./scripts/docker-deploy.sh
|
||||||
|
# or: bun run docker:deploy
|
||||||
|
# or: docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
`--build` is important. A plain `docker compose up -d` reuses the existing image and **does not** pick up new code from `git pull`.
|
`--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:<sha>`) so each deploy gets a distinct image.
|
||||||
|
|
||||||
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
|
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
|
||||||
|
|
||||||
|
### Scheduled recurring invoices
|
||||||
|
|
||||||
|
The app container does not run a cron daemon. It starts the web server with
|
||||||
|
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
|
||||||
|
when something calls `POST /api/cron/generate-recurring` with
|
||||||
|
`Authorization: Bearer $CRON_SECRET`.
|
||||||
|
|
||||||
|
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
|
||||||
|
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
|
||||||
|
external scheduler to call
|
||||||
|
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
|
||||||
|
|
||||||
### 3. Updating an existing deploy
|
### 3. Updating an existing deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
docker compose up -d --build # rebuild image, restart app, run any new migrations
|
./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
|
||||||
|
# or: docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
| Command | New code? | Migrations run? |
|
| Command | New code? | Migrations run? |
|
||||||
|---------|-----------|-----------------|
|
|---------|-----------|-----------------|
|
||||||
| `git pull` only | No | No |
|
| `git pull` only | No | No |
|
||||||
| `docker compose up -d` (no `--build`) | No — old image | Only if the app container restarts (same image) |
|
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
|
||||||
| `docker compose up -d --build` | Yes | Yes — on app container start |
|
| `./scripts/docker-deploy.sh` or `docker compose up -d --build` | Yes | Yes — on app container start |
|
||||||
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
|
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
|
||||||
|
|
||||||
|
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
|
||||||
|
|
||||||
To verify migration files match the journal before deploy: `bun run db:verify-journal`.
|
To verify migration files match the journal before deploy: `bun run db:verify-journal`.
|
||||||
|
|
||||||
|
### Coolify
|
||||||
|
|
||||||
|
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
|
||||||
|
|
||||||
### 4. Sign-ups
|
### 4. Sign-ups
|
||||||
|
|
||||||
Registration is **enabled** by default. To block new email/password accounts:
|
Registration is **enabled** by default. To block new email/password accounts:
|
||||||
@@ -176,7 +206,9 @@ beenvoice-web/
|
|||||||
├── src/lib/ # auth, PDF, email, branding helpers
|
├── src/lib/ # auth, PDF, email, branding helpers
|
||||||
├── drizzle/ # SQL migrations
|
├── drizzle/ # SQL migrations
|
||||||
├── Dockerfile # Production image (migrate + next start)
|
├── Dockerfile # Production image (migrate + next start)
|
||||||
├── docker-compose.yml # App + Postgres (deploy)
|
├── docker-compose.yml # App + Postgres + Garage (deploy)
|
||||||
|
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
|
||||||
|
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
|
||||||
├── docker-compose.dev.yml # Postgres only (local dev)
|
├── docker-compose.dev.yml # Postgres only (local dev)
|
||||||
└── docs/ # Architecture and UI guides
|
└── docs/ # Architecture and UI guides
|
||||||
```
|
```
|
||||||
@@ -204,12 +236,13 @@ bun run lint:fix
|
|||||||
bun run format:write
|
bun run format:write
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
|
|
||||||
# Docker helpers (Postgres only — uses Colima on macOS)
|
# Docker helpers
|
||||||
bun run docker:up # colima start + docker-compose.dev.yml up -d
|
bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
|
||||||
bun run docker:down # stop dev Postgres + colima
|
bun run docker:down # stop dev Postgres + colima
|
||||||
|
bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Full-stack deploy uses `docker compose up` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
|
Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
|
||||||
|
|
||||||
## API surface
|
## API surface
|
||||||
|
|
||||||
@@ -235,6 +268,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
|
|||||||
| Doc | Contents |
|
| Doc | Contents |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
|
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
|
||||||
|
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
|
||||||
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
|
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
|
||||||
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp;
|
||||||
|
|
||||||
|
-- Users who already have a business are treated as onboarded
|
||||||
|
UPDATE "beenvoice_user" u
|
||||||
|
SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW())
|
||||||
|
WHERE u."onboardingCompletedAt" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "beenvoice_business" b
|
||||||
|
WHERE b."createdById" = u."id"
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_audit_log" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"actorUserId" varchar(255) NOT NULL,
|
||||||
|
"action" varchar(100) NOT NULL,
|
||||||
|
"targetType" varchar(50) NOT NULL,
|
||||||
|
"targetId" varchar(255),
|
||||||
|
"metadata" jsonb,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_audit_log"
|
||||||
|
ADD CONSTRAINT "beenvoice_audit_log_actorUserId_beenvoice_user_id_fk"
|
||||||
|
FOREIGN KEY ("actorUserId") REFERENCES "public"."beenvoice_user"("id")
|
||||||
|
ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx" ON "beenvoice_audit_log" USING btree ("actorUserId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "beenvoice_audit_log" USING btree ("action");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx" ON "beenvoice_audit_log" USING btree ("createdAt");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS "expense_business_id_idx" ON "beenvoice_expense" USING btree ("businessId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_expense_receipt" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"expenseId" varchar(255) NOT NULL,
|
||||||
|
"storageKey" varchar(500) NOT NULL,
|
||||||
|
"originalFilename" varchar(255) NOT NULL,
|
||||||
|
"mimeType" varchar(100) NOT NULL,
|
||||||
|
"sizeBytes" integer NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense_receipt"
|
||||||
|
ADD CONSTRAINT "beenvoice_expense_receipt_expenseId_beenvoice_expense_id_fk"
|
||||||
|
FOREIGN KEY ("expenseId") REFERENCES "public"."beenvoice_expense"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "expense_receipt_expense_id_idx" ON "beenvoice_expense_receipt" USING btree ("expenseId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_expense" e
|
||||||
|
SET "businessId" = i."businessId"
|
||||||
|
FROM "beenvoice_invoice" i
|
||||||
|
WHERE e."invoiceId" = i.id
|
||||||
|
AND e."businessId" IS NULL
|
||||||
|
AND i."businessId" IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_expense" e
|
||||||
|
SET "businessId" = sub.business_id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
e2.id AS expense_id,
|
||||||
|
(
|
||||||
|
SELECT b2.id
|
||||||
|
FROM "beenvoice_business" b2
|
||||||
|
WHERE b2."createdById" = e2."createdById"
|
||||||
|
ORDER BY b2."isDefault" DESC, b2."createdAt" DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS business_id
|
||||||
|
FROM "beenvoice_expense" e2
|
||||||
|
WHERE e2."businessId" IS NULL
|
||||||
|
) sub
|
||||||
|
WHERE e.id = sub.expense_id
|
||||||
|
AND sub.business_id IS NOT NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
|
||||||
@@ -120,6 +120,48 @@
|
|||||||
"when": 1781500000000,
|
"when": 1781500000000,
|
||||||
"tag": "0016_fix_send_reminder_at_column",
|
"tag": "0016_fix_send_reminder_at_column",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781600000000,
|
||||||
|
"tag": "0017_drop_theme_engine_columns",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781700000000,
|
||||||
|
"tag": "0018_user_onboarding",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781800000000,
|
||||||
|
"tag": "0019_pdf_font_family",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781900000000,
|
||||||
|
"tag": "0020_pdf_numeric_font_family",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782000000000,
|
||||||
|
"tag": "0021_audit_log",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782200000000,
|
||||||
|
"tag": "0023_invoice_item_time_entry",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle";
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [".next"],
|
ignores: [".next", "scripts/**"],
|
||||||
},
|
},
|
||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,10 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
import { Resend } from "resend";
|
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||||
import { env } from "~/env";
|
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -15,22 +13,35 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
if (ipRateLimit) return ipRateLimit;
|
||||||
|
|
||||||
|
const emailRateLimit = requireRateLimit(
|
||||||
|
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
|
||||||
|
{
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (emailRateLimit) return emailRateLimit;
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(normalizedEmail)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid email format" },
|
{ error: "Invalid email format" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email.toLowerCase()),
|
where: eq(users.email, normalizedEmail),
|
||||||
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always return success to prevent email enumeration attacks
|
|
||||||
// Don't reveal whether the user exists or not
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -42,60 +53,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate reset token
|
await sendPasswordResetForUser(user.id);
|
||||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
|
||||||
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
||||||
|
|
||||||
// Update user with reset token
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
resetToken,
|
|
||||||
resetTokenExpiry,
|
|
||||||
})
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
|
|
||||||
if (!env.RESEND_API_KEY) {
|
|
||||||
console.warn(
|
|
||||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
|
||||||
);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
"If an account with that email exists, password reset instructions have been sent.",
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send password reset email using Resend
|
|
||||||
try {
|
|
||||||
const resend = new Resend(env.RESEND_API_KEY);
|
|
||||||
const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`;
|
|
||||||
|
|
||||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
|
||||||
userEmail: email,
|
|
||||||
userName: user.name ?? undefined,
|
|
||||||
resetToken,
|
|
||||||
resetUrl,
|
|
||||||
expiryHours: 24,
|
|
||||||
});
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: "beenvoice <noreply@beenvoice.com>",
|
|
||||||
to: email,
|
|
||||||
subject: emailTemplate.subject,
|
|
||||||
html: emailTemplate.html,
|
|
||||||
text: emailTemplate.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Password reset email sent to: ${email}`);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error("Failed to send password reset email:", emailError);
|
|
||||||
// Continue execution - don't fail the request if email fails
|
|
||||||
// This prevents revealing whether an account exists based on email delivery
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { eq } from "drizzle-orm";
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
|
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||||
|
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||||
|
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
@@ -69,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
});
|
||||||
|
if (rateLimit) return rateLimit;
|
||||||
|
|
||||||
if (env.DISABLE_SIGNUPS === true) {
|
if (env.DISABLE_SIGNUPS === true) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "New account registration is currently disabled" },
|
{ error: "New account registration is currently disabled" },
|
||||||
@@ -104,13 +113,22 @@ export async function POST(request: NextRequest) {
|
|||||||
const { firstName, lastName, email, password } = parsed.data;
|
const { firstName, lastName, email, password } = parsed.data;
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
const emailRateLimit = requireRateLimit(
|
||||||
|
rateLimitKey(request, "auth:register-email", normalizedEmail),
|
||||||
|
{
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (emailRateLimit) return emailRateLimit;
|
||||||
|
|
||||||
const existingUser = await db.query.users.findFirst({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.email, normalizedEmail),
|
where: eq(users.email, normalizedEmail),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "User with this email already exists" },
|
{ error: "Registration failed. Please check the form or sign in." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,12 +136,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
const role = await resolveNewUserRole(tx);
|
||||||
|
|
||||||
const [user] = await tx
|
const [user] = await tx
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName} ${lastName}`,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
role,
|
||||||
})
|
})
|
||||||
.returning({ id: users.id });
|
.returning({ id: users.id });
|
||||||
|
|
||||||
@@ -161,6 +182,12 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Registration error:", error);
|
console.error("Registration error:", error);
|
||||||
|
|
||||||
|
const databaseSetupError = getDatabaseSetupErrorMessage(error);
|
||||||
|
if (databaseSetupError) {
|
||||||
|
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z, type ZodType } from "zod";
|
|||||||
|
|
||||||
import { createCaller } from "~/server/api/root";
|
import { createCaller } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/trpc";
|
import { createTRPCContext } from "~/server/api/trpc";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -856,7 +857,7 @@ const tools = {
|
|||||||
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
||||||
handler: async (input, caller) => {
|
handler: async (input, caller) => {
|
||||||
const result = await caller.invoices.generatePublicToken(input);
|
const result = await caller.invoices.generatePublicToken(input);
|
||||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
const base = getAppUrl();
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
webUrl: `${base}/i/${result.token}`,
|
webUrl: `${base}/i/${result.token}`,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||||
|
import { getObject } from "~/lib/object-storage";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { expenseReceipts } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getOptionalServerSession(req.headers);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const receipt = await db.query.expenseReceipts.findFirst({
|
||||||
|
where: eq(expenseReceipts.id, id),
|
||||||
|
with: { expense: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (receipt?.expense.createdById !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await getObject(receipt.storageKey);
|
||||||
|
return new NextResponse(new Uint8Array(body), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": receipt.mimeType,
|
||||||
|
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,213 +1,6 @@
|
|||||||
"use client";
|
import { env } from "~/env";
|
||||||
|
import { RegisterForm } from "./register-form";
|
||||||
import { useState, Suspense } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Logo } from "~/components/branding/logo";
|
|
||||||
import { LegalAgreementNotice } from "~/components/legal/legal-links";
|
|
||||||
import { Mail, Lock, ArrowRight, User } from "lucide-react";
|
|
||||||
|
|
||||||
function formatAuthError(message: string | undefined, fallback: string): string {
|
|
||||||
if (!message || message === "Required") {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegisterForm() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const trimmedFirstName = String(formData.get("firstName") ?? "").trim();
|
|
||||||
const trimmedLastName = String(formData.get("lastName") ?? "").trim();
|
|
||||||
const trimmedEmail = String(formData.get("email") ?? "").trim();
|
|
||||||
const password = String(formData.get("password") ?? "");
|
|
||||||
|
|
||||||
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
|
|
||||||
toast.error("Please enter your first name, last name, and email.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
toast.error("Password must be at least 8 characters.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/auth/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
firstName: trimmedFirstName,
|
|
||||||
lastName: trimmedLastName,
|
|
||||||
email: trimmedEmail,
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: { error?: string; signInRequired?: boolean } = {};
|
|
||||||
try {
|
|
||||||
data = (await res.json()) as typeof data;
|
|
||||||
} catch {
|
|
||||||
toast.error("Registration failed. Please try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
toast.error(
|
|
||||||
formatAuthError(data.error, "Registration failed. Please check the form."),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.signInRequired) {
|
|
||||||
toast.success("Account created! Please sign in.");
|
|
||||||
router.push("/auth/signin");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Account created successfully!");
|
|
||||||
router.push("/dashboard");
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast.error("Registration failed. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
|
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
|
|
||||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mx-auto w-full max-w-md border-border/50 bg-background/80 backdrop-blur-xl">
|
|
||||||
<CardContent className="p-8">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Logo size="lg" />
|
|
||||||
<div>
|
|
||||||
<h1 className="font-heading text-2xl font-bold">Create your account</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">Get started today</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="firstName">First Name</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="firstName"
|
|
||||||
name="firstName"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
autoComplete="given-name"
|
|
||||||
className="h-10 pl-10"
|
|
||||||
placeholder="John"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lastName">Last Name</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="lastName"
|
|
||||||
name="lastName"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="family-name"
|
|
||||||
className="h-10 pl-10"
|
|
||||||
placeholder="Doe"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autoComplete="email"
|
|
||||||
className="h-10 pl-10"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="h-10 pl-10"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs">At least 8 characters</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="h-10 w-full" disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2" />
|
|
||||||
<span>Creating account…</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span>Create Account</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-center text-sm">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<a href="/auth/signin" className="text-foreground font-medium hover:underline">
|
|
||||||
Sign in
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LegalAgreementNotice action="creating an account" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<RegisterForm />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowRight, Lock, Mail, User, UserX } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AuthCard,
|
||||||
|
AuthCardHeader,
|
||||||
|
AuthPageShell,
|
||||||
|
} from "~/components/auth/auth-page-shell";
|
||||||
|
import { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function formatAuthError(message: string | undefined, fallback: string): string {
|
||||||
|
if (!message || message === "Required") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
signupsDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleRegister(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const trimmedFirstName = firstName.trim();
|
||||||
|
const trimmedLastName = lastName.trim();
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
|
||||||
|
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
|
||||||
|
toast.error("Please enter your first name, last name, and email.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
firstName: trimmedFirstName,
|
||||||
|
lastName: trimmedLastName,
|
||||||
|
email: trimmedEmail,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let data: { error?: string; signInRequired?: boolean } = {};
|
||||||
|
try {
|
||||||
|
data = (await res.json()) as typeof data;
|
||||||
|
} catch {
|
||||||
|
toast.error("Registration failed. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(
|
||||||
|
formatAuthError(data.error, "Registration failed. Please check the form."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.signInRequired) {
|
||||||
|
toast.success("Account created! Please sign in.");
|
||||||
|
router.push("/auth/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Account created successfully!");
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("Registration failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signupsDisabled) {
|
||||||
|
return (
|
||||||
|
<AuthPageShell>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader
|
||||||
|
title="Registration closed"
|
||||||
|
description="New account sign-ups are not available right now"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 text-muted-foreground mb-6 flex gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||||
|
<UserX className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p>
|
||||||
|
This workspace is not accepting new registrations. If you already
|
||||||
|
have an account, sign in below. Contact your administrator if you
|
||||||
|
need access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild className="h-11 w-full">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
Sign in to your account
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</AuthCard>
|
||||||
|
</AuthPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageShell>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader
|
||||||
|
title="Create your account"
|
||||||
|
description="Get started with your workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">First name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="given-name"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">Last name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="family-name"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">At least 8 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="h-11 w-full" disabled={loading}>
|
||||||
|
{loading ? "Creating account…" : "Create account"}
|
||||||
|
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-foreground font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LegalAgreementNotice action="creating an account" className="mt-5" />
|
||||||
|
</AuthCard>
|
||||||
|
</AuthPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
|
|||||||
import { Label } from "~/components/ui/label";
|
import { 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
|
||||||
|
|||||||
@@ -7,13 +7,37 @@ import { Card, CardContent } from "~/components/ui/card";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Square, Clock } from "lucide-react";
|
import { Square, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
import {
|
||||||
|
describeClockOutOutcome,
|
||||||
|
formatElapsedSeconds,
|
||||||
|
formatRunningTimerLabel,
|
||||||
|
} from "~/lib/time-clock";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "~/components/ui/tooltip";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
export function ActiveTimerWidget() {
|
interface ActiveTimerWidgetProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveTimerWidget({
|
||||||
|
collapsed = false,
|
||||||
|
compact = false,
|
||||||
|
}: ActiveTimerWidgetProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
|
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
|
||||||
refetchInterval: 30_000,
|
undefined,
|
||||||
});
|
{
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -56,9 +80,6 @@ export function ActiveTimerWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void utils.timeEntries.getRunning.invalidate();
|
void utils.timeEntries.getRunning.invalidate();
|
||||||
void utils.timeEntries.getAll.invalidate();
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
void utils.dashboard.getStats.invalidate();
|
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -69,64 +90,153 @@ export function ActiveTimerWidget() {
|
|||||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const description = formatRunningTimerLabel(running.description);
|
||||||
|
|
||||||
|
const renderStopButton = (className?: string) => (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clockOut.mutate({})}
|
||||||
|
disabled={clockOut.isPending}
|
||||||
|
className={cn(compact && "h-8 px-2", className)}
|
||||||
|
>
|
||||||
|
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} />
|
||||||
|
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="ml-auto flex min-w-0 items-center gap-1.5">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/time-clock"
|
||||||
|
className="border-primary/30 bg-primary/5 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<span className="text-primary truncate font-mono text-sm font-bold tabular-nums">
|
||||||
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{renderStopButton("shrink-0")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/time-clock"
|
||||||
|
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
<span className="absolute top-1 right-1 flex h-2 w-2">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{description}
|
||||||
|
{running.client && (
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
{" "}
|
||||||
|
· {running.client.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-primary font-mono text-lg font-bold tabular-nums">
|
||||||
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</p>
|
||||||
|
{invoiceLabel ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Billing to{" "}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{invoiceLabel}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">No invoice selected</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
|
||||||
|
<Link href="/dashboard/time-clock">Open</Link>
|
||||||
|
</Button>
|
||||||
|
{renderStopButton()}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-primary/30 bg-primary/5">
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
<CardContent className="flex flex-col gap-3 p-3">
|
||||||
<span className="relative flex h-3 w-3 flex-shrink-0">
|
<div className="flex items-start gap-2">
|
||||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
|
||||||
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
</span>
|
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||||
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm leading-snug font-medium">
|
||||||
{running.description || (
|
{description}
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
{running.client && (
|
||||||
)}
|
<span className="text-muted-foreground font-normal">
|
||||||
{running.client && (
|
{" "}
|
||||||
<span className="text-muted-foreground font-normal"> · {running.client.name}</span>
|
· {running.client.name}
|
||||||
)}
|
</span>
|
||||||
</p>
|
)}
|
||||||
<p className="text-muted-foreground text-xs">
|
</p>
|
||||||
{invoiceLabel ? (
|
<p className="text-muted-foreground mt-1 text-xs leading-snug">
|
||||||
<>
|
{invoiceLabel ? (
|
||||||
Billing to{" "}
|
<>
|
||||||
<Link
|
Billing to{" "}
|
||||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
<Link
|
||||||
className="text-primary hover:underline"
|
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||||
>
|
className="text-primary hover:underline"
|
||||||
{invoiceLabel}
|
>
|
||||||
</Link>
|
{invoiceLabel}
|
||||||
</>
|
</Link>
|
||||||
) : (
|
</>
|
||||||
<>No invoice selected — open time clock to assign</>
|
) : (
|
||||||
)}
|
<>No invoice selected — open time clock to assign</>
|
||||||
{" · "}
|
)}
|
||||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
{" · "}
|
||||||
Time clock
|
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||||
</Link>
|
Time clock
|
||||||
</p>
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{formatElapsedSeconds(elapsed)}
|
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
|
||||||
</span>
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild className="h-8 w-full">
|
||||||
<Link href="/dashboard/time-clock">
|
<Link href="/dashboard/time-clock">
|
||||||
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
<Clock className="mr-1 h-3.5 w-3.5" />
|
||||||
Open
|
Open
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{renderStopButton("w-full")}
|
||||||
variant="destructive"
|
</div>
|
||||||
size="sm"
|
|
||||||
onClick={() => clockOut.mutate({})}
|
|
||||||
disabled={clockOut.isPending}
|
|
||||||
>
|
|
||||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
{clockOut.isPending ? "Stopping…" : "Stop"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
|
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
|
||||||
|
|
||||||
@@ -51,41 +58,36 @@ export function AnimatedStatsCard({
|
|||||||
const isPositive = trend === "up";
|
const isPositive = trend === "up";
|
||||||
const isNeutral = trend === "neutral";
|
const isNeutral = trend === "neutral";
|
||||||
|
|
||||||
// For now, always use the formatted value prop to ensure correct display
|
|
||||||
// Animation can be added back once the basic display is working correctly
|
|
||||||
const displayValue = value;
|
|
||||||
|
|
||||||
// Suppress unused parameter warnings for now
|
|
||||||
void delay;
|
void delay;
|
||||||
void isCurrency;
|
void isCurrency;
|
||||||
void numericValue;
|
void numericValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
<CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
|
||||||
<div className="flex items-center space-x-2">
|
<Icon className="h-4 w-4" />
|
||||||
<Icon className="text-muted-foreground h-5 w-5" />
|
{title}
|
||||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
</CardTitle>
|
||||||
</div>
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className="flex items-center space-x-1 text-xs"
|
"flex items-center gap-1 text-xs font-medium",
|
||||||
style={{
|
isNeutral
|
||||||
color: isNeutral
|
? "text-muted-foreground"
|
||||||
? "hsl(var(--muted-foreground))"
|
: isPositive
|
||||||
: isPositive
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
? "oklch(var(--chart-2))"
|
: "text-amber-600 dark:text-amber-400",
|
||||||
: "oklch(var(--chart-3))",
|
)}
|
||||||
}}
|
>
|
||||||
>
|
<TrendIcon className="h-3 w-3" />
|
||||||
<TrendIcon className="h-3 w-3" />
|
<span className="font-mono tabular-nums">{change}</span>
|
||||||
<span>{change}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
|
|
||||||
<p className="text-muted-foreground text-xs">{description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="font-mono text-2xl font-semibold tracking-tight tabular-nums">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
<CardDescription className="mt-1">{description}</CardDescription>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
import { Cell, Pie, PieChart, Tooltip } from "recharts";
|
||||||
|
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
|
|
||||||
interface Invoice {
|
export interface StatusChartDatum {
|
||||||
id: string;
|
|
||||||
totalAmount: number;
|
|
||||||
status: string;
|
status: string;
|
||||||
dueDate: Date | string;
|
name: string;
|
||||||
|
count: number;
|
||||||
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceStatusChartProps {
|
interface InvoiceStatusChartProps {
|
||||||
invoices: Invoice[];
|
data: StatusChartDatum[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
@@ -47,52 +46,26 @@ function StatusTooltip({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
<p className="font-medium">{data.name}</p>
|
<p className="font-medium">{data.name}</p>
|
||||||
<p className="text-sm">
|
<p className="font-mono text-sm tabular-nums">
|
||||||
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{formatChartCurrency(data.value)}</p>
|
<p className="font-mono text-sm tabular-nums">
|
||||||
|
{formatChartCurrency(data.value)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) {
|
||||||
// Process invoice data to create status breakdown
|
|
||||||
const statusData = invoices.reduce(
|
|
||||||
(acc, invoice) => {
|
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
acc[effectiveStatus] ??= {
|
|
||||||
status: effectiveStatus,
|
|
||||||
count: 0,
|
|
||||||
value: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
acc[effectiveStatus].count += 1;
|
|
||||||
acc[effectiveStatus].value += invoice.totalAmount;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, { status: string; count: number; value: number }>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const chartData = Object.values(statusData).map((item) => ({
|
|
||||||
...item,
|
|
||||||
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Animation / motion preferences
|
|
||||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
useAnimationPreferences();
|
useAnimationPreferences();
|
||||||
const pieAnimationDuration = Math.round(
|
const pieAnimationDuration = Math.round(
|
||||||
600 / (animationSpeedMultiplier || 1),
|
600 / (animationSpeedMultiplier || 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="h-48 w-full">
|
<ResponsiveChart height={192} className="h-48">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<PieChart>
|
||||||
<PieChart>
|
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={data}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={40}
|
innerRadius={40}
|
||||||
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
animationDuration={pieAnimationDuration}
|
animationDuration={pieAnimationDuration}
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
>
|
>
|
||||||
{chartData.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={
|
fill={
|
||||||
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip content={<StatusTooltip />} />
|
<Tooltip content={<StatusTooltip />} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveChart>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{chartData.map((item) => (
|
{data.map((item) => (
|
||||||
<div key={item.status} className="flex items-center justify-between">
|
<div key={item.status} className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
<span className="text-sm font-medium">{item.name}</span>
|
<span className="text-sm font-medium">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium">{item.count}</p>
|
<p className="font-mono text-sm font-medium tabular-nums">
|
||||||
<p className="text-muted-foreground text-xs">
|
{item.count}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||||
{formatChartCurrency(item.value)}
|
{formatChartCurrency(item.value)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,25 +3,25 @@
|
|||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
interface Invoice {
|
export interface MonthlyMetricsChartDatum {
|
||||||
id: string;
|
month: string;
|
||||||
totalAmount: number;
|
monthLabel: string;
|
||||||
issueDate: Date | string;
|
totalInvoices: number;
|
||||||
status: string;
|
paidInvoices: number;
|
||||||
dueDate: Date | string;
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
draftInvoices: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MonthlyMetricsChartProps {
|
interface MonthlyMetricsChartProps {
|
||||||
invoices: Invoice[];
|
data: MonthlyMetricsChartDatum[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MonthlyMetricsTooltip({
|
function MonthlyMetricsTooltip({
|
||||||
@@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({
|
|||||||
}: {
|
}: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{
|
payload?: Array<{
|
||||||
payload: {
|
payload: MonthlyMetricsChartDatum;
|
||||||
paidInvoices: number;
|
|
||||||
pendingInvoices: number;
|
|
||||||
overdueInvoices: number;
|
|
||||||
draftInvoices: number;
|
|
||||||
totalInvoices: number;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
if (active && payload?.length) {
|
if (active && payload?.length) {
|
||||||
const data = payload[0]!.payload;
|
const chartDatum = payload[0]!.payload;
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
<p className="font-medium">{label}</p>
|
<p className="font-medium">{label}</p>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
|
<p className="text-primary font-medium font-mono tabular-nums">
|
||||||
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
|
Paid: {chartDatum.paidInvoices}
|
||||||
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
|
</p>
|
||||||
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
|
<p className="text-primary/80 font-mono tabular-nums">
|
||||||
<p className="text-foreground border-t pt-1 font-medium">
|
Pending: {chartDatum.pendingInvoices}
|
||||||
Total: {data.totalInvoices}
|
</p>
|
||||||
|
<p className="text-destructive font-mono tabular-nums">
|
||||||
|
Overdue: {chartDatum.overdueInvoices}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground font-mono tabular-nums">
|
||||||
|
Draft: {chartDatum.draftInvoices}
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground border-t pt-1 font-medium font-mono tabular-nums">
|
||||||
|
Total: {chartDatum.totalInvoices}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) {
|
||||||
// Process invoice data to create monthly metrics
|
|
||||||
const monthlyData = invoices.reduce(
|
|
||||||
(acc, invoice) => {
|
|
||||||
const date = new Date(invoice.issueDate);
|
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
acc[monthKey] ??= {
|
|
||||||
month: monthKey,
|
|
||||||
totalInvoices: 0,
|
|
||||||
paidInvoices: 0,
|
|
||||||
pendingInvoices: 0,
|
|
||||||
overdueInvoices: 0,
|
|
||||||
draftInvoices: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
acc[monthKey].totalInvoices += 1;
|
|
||||||
|
|
||||||
switch (effectiveStatus) {
|
|
||||||
case "paid":
|
|
||||||
acc[monthKey].paidInvoices += 1;
|
|
||||||
break;
|
|
||||||
case "sent":
|
|
||||||
acc[monthKey].pendingInvoices += 1;
|
|
||||||
break;
|
|
||||||
case "overdue":
|
|
||||||
acc[monthKey].overdueInvoices += 1;
|
|
||||||
break;
|
|
||||||
case "draft":
|
|
||||||
acc[monthKey].draftInvoices += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
month: string;
|
|
||||||
totalInvoices: number;
|
|
||||||
paidInvoices: number;
|
|
||||||
pendingInvoices: number;
|
|
||||||
overdueInvoices: number;
|
|
||||||
draftInvoices: number;
|
|
||||||
}
|
|
||||||
>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to array and sort by month
|
|
||||||
const chartData = Object.values(monthlyData)
|
|
||||||
.sort((a, b) => a.month.localeCompare(b.month))
|
|
||||||
.slice(-6) // Show last 6 months
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
year: "2-digit",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Animation / motion preferences
|
|
||||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
useAnimationPreferences();
|
useAnimationPreferences();
|
||||||
const barAnimationDuration = Math.round(
|
const barAnimationDuration = Math.round(
|
||||||
500 / (animationSpeedMultiplier || 1),
|
500 / (animationSpeedMultiplier || 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="h-48 w-full">
|
<ResponsiveChart height={192} className="h-48">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<BarChart data={data}>
|
||||||
<BarChart data={chartData}>
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="monthLabel"
|
dataKey="monthLabel"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
tick={{
|
||||||
|
fontSize: 12,
|
||||||
|
fill: "var(--muted-foreground)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<MonthlyMetricsTooltip />} />
|
<Tooltip content={<MonthlyMetricsTooltip />} />
|
||||||
<Bar
|
<Bar
|
||||||
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveChart>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
interface RevenueChartProps {
|
interface RevenueChartProps {
|
||||||
@@ -41,7 +41,10 @@ const CustomTooltip = ({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
<p className="font-medium">{label}</p>
|
<p className="font-medium">{label}</p>
|
||||||
<p style={{ color: "hsl(0, 0%, 60%)" }}>
|
<p
|
||||||
|
className="font-mono tabular-nums"
|
||||||
|
style={{ color: "hsl(0, 0%, 60%)" }}
|
||||||
|
>
|
||||||
Revenue: {formatCurrency(data.revenue)}
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -84,9 +87,8 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-48 w-full md:h-64">
|
<ResponsiveChart height={256} className="h-48 md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<AreaChart data={chartData}>
|
||||||
<AreaChart data={chartData}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
tick={{
|
||||||
|
fontSize: 12,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
tickFormatter={formatCurrency}
|
tickFormatter={formatCurrency}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveChart>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Shield } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
Building2,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
ScrollText,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDeferredValue, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
|
import { dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -9,6 +41,15 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -18,84 +59,563 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export function AdministrationContent() {
|
const PAGE_SIZE = 25;
|
||||||
const {
|
|
||||||
data: accounts = [],
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
refetch,
|
"user.profile_updated": "Profile updated",
|
||||||
error,
|
"user.role_updated": "Role updated",
|
||||||
} = api.settings.listAccounts.useQuery();
|
"user.password_reset_sent": "Password reset sent",
|
||||||
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
"platform.pdf_settings_updated": "PDF settings updated",
|
||||||
onSuccess: () => {
|
};
|
||||||
toast.success("Account role updated");
|
|
||||||
void refetch();
|
function formatAction(action: string) {
|
||||||
},
|
return ACTION_LABELS[action] ?? action;
|
||||||
onError: (mutationError: { message: string }) => {
|
}
|
||||||
toast.error(`Failed to update role: ${mutationError.message}`);
|
|
||||||
},
|
function AdminOverview() {
|
||||||
});
|
const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle>Platform overview</CardTitle>
|
||||||
|
<CardDescription>Unable to load statistics.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: "Total users",
|
||||||
|
value: stats?.totalUsers ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Active (${stats?.activeUserWindowDays ?? 30}d)`,
|
||||||
|
value: stats?.activeUsers ?? 0,
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Administrators",
|
||||||
|
value: stats?.adminCount ?? 0,
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Invoices",
|
||||||
|
value: stats?.totalInvoices ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Businesses",
|
||||||
|
value: stats?.totalBusinesses ?? 0,
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clients",
|
||||||
|
value: stats?.totalClients ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Time entries",
|
||||||
|
value: stats?.totalTimeEntries ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="text-primary h-5 w-5" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
Administration
|
Platform overview
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Administrative access is required for this page.
|
Aggregate counts only — no customer data, credentials, or bulk PII.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading statistics…</p>
|
||||||
|
) : (
|
||||||
|
<div className={dashboardStatGridClass}>
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Card key={stat.label}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||||
|
<stat.icon className="h-3.5 w-3.5" />
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditUserState = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "user" | "admin";
|
||||||
|
};
|
||||||
|
|
||||||
|
function AdminUsers() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [editUser, setEditUser] = useState<EditUserState | null>(null);
|
||||||
|
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
|
const [resetUserName, setResetUserName] = useState("");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data, isLoading, error, isFetching } = api.admin.listUsers.useQuery({
|
||||||
|
search: deferredSearch || undefined,
|
||||||
|
offset,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = api.admin.updateUser.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("User updated");
|
||||||
|
setEditUser(null);
|
||||||
|
void utils.admin.listUsers.invalidate();
|
||||||
|
void utils.admin.listAuditLog.invalidate();
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error(mutationError.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendResetMutation = api.admin.sendPasswordReset.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.emailSent) {
|
||||||
|
toast.success("Password reset email sent");
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
"Reset token created, but email could not be sent. Check Resend configuration.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setResetUserId(null);
|
||||||
|
void utils.admin.listAuditLog.invalidate();
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error(mutationError.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>Administrative access is required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="text-primary h-5 w-5" />
|
||||||
|
Users
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search accounts, edit profiles, and manage access.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search by name or email…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading users…</p>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users className="h-6 w-6" />}
|
||||||
|
title="No users found"
|
||||||
|
description={
|
||||||
|
deferredSearch
|
||||||
|
? "Try a different search term."
|
||||||
|
: "No accounts have been created yet."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-border divide-y border">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{user.name}</p>
|
||||||
|
<Badge
|
||||||
|
variant={user.role === "admin" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
{user.emailVerified ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Joined{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setEditUser({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role as "user" | "admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setResetUserId(user.id);
|
||||||
|
setResetUserName(user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > PAGE_SIZE ? (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Page {currentPage} of {totalPages} · {total} users
|
||||||
|
{isFetching ? " · Updating…" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editUser != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit user</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editUser ? (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateUserMutation.mutate({
|
||||||
|
userId: editUser.id,
|
||||||
|
name: editUser.name,
|
||||||
|
email: editUser.email,
|
||||||
|
role: editUser.role,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-user-name"
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, name: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-user-email"
|
||||||
|
type="email"
|
||||||
|
value={editUser.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, email: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={editUser.role}
|
||||||
|
onValueChange={(role) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, role: role as "user" | "admin" }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="edit-user-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditUser(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateUserMutation.isPending}>
|
||||||
|
{updateUserMutation.isPending ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={resetUserId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setResetUserId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Send password reset?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
A password reset email will be sent to{" "}
|
||||||
|
<span className="font-medium">{resetUserName}</span>. The link
|
||||||
|
expires in 24 hours.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={sendResetMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (resetUserId) {
|
||||||
|
sendResetMutation.mutate({ userId: resetUserId });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sendResetMutation.isPending ? "Sending…" : "Send reset email"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminAuditLog() {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const { data, isLoading, error, isFetching } = api.admin.listAuditLog.useQuery(
|
||||||
|
{
|
||||||
|
offset,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit log</CardTitle>
|
||||||
|
<CardDescription>Administrative access is required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="text-primary h-5 w-5" />
|
<ScrollText className="text-primary h-5 w-5" />
|
||||||
Accounts
|
Audit log
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage account access and roles without opening customer data.
|
Recent administrative actions across the platform.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent>
|
||||||
{accounts.map((account) => (
|
{isLoading ? (
|
||||||
<div
|
<p className="text-muted-foreground text-sm">Loading audit log…</p>
|
||||||
key={account.id}
|
) : entries.length === 0 ? (
|
||||||
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
|
<EmptyState
|
||||||
>
|
icon={<ScrollText className="h-6 w-6" />}
|
||||||
<div className="min-w-0">
|
title="No audit events yet"
|
||||||
<p className="text-sm font-medium">{account.name}</p>
|
description="Administrative actions will appear here."
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
/>
|
||||||
{account.email}
|
) : (
|
||||||
</p>
|
<div className="divide-border divide-y border">
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
{entries.map((entry) => (
|
||||||
Created {new Date(account.createdAt).toLocaleDateString()}
|
<div key={entry.id} className="space-y-1 p-4">
|
||||||
</p>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<p className="text-sm font-medium">
|
||||||
<Select
|
{formatAction(entry.action)}
|
||||||
value={account.role}
|
</p>
|
||||||
onValueChange={(role) =>
|
<Badge variant="outline" className="text-xs">
|
||||||
updateAccountRoleMutation.mutate({
|
{entry.targetType}
|
||||||
userId: account.id,
|
</Badge>
|
||||||
role: role as "user" | "admin",
|
</div>
|
||||||
})
|
<p className="text-muted-foreground text-xs">
|
||||||
}
|
{entry.actor?.name ?? "Unknown admin"} ·{" "}
|
||||||
>
|
{new Date(entry.createdAt).toLocaleString(undefined, {
|
||||||
<SelectTrigger className="w-full sm:w-36">
|
month: "short",
|
||||||
<SelectValue />
|
day: "numeric",
|
||||||
</SelectTrigger>
|
year: "numeric",
|
||||||
<SelectContent>
|
hour: "numeric",
|
||||||
<SelectItem value="user">User</SelectItem>
|
minute: "2-digit",
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
})}
|
||||||
</SelectContent>
|
{entry.targetId ? ` · target ${entry.targetId.slice(0, 8)}…` : ""}
|
||||||
</Select>
|
</p>
|
||||||
|
{entry.metadata &&
|
||||||
|
Object.keys(entry.metadata).length > 0 ? (
|
||||||
|
<p className="text-muted-foreground font-mono text-xs break-all">
|
||||||
|
{JSON.stringify(entry.metadata)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{total > PAGE_SIZE ? (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Page {currentPage} of {totalPages} · {total} events
|
||||||
|
{isFetching ? " · Updating…" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AdministrationContent() {
|
||||||
|
return (
|
||||||
|
<PageTabs defaultValue="overview">
|
||||||
|
<PageTabsList>
|
||||||
|
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="users">Users</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="audit">Audit log</PageTabsTrigger>
|
||||||
|
</PageTabsList>
|
||||||
|
|
||||||
|
<PageTabsContent value="overview">
|
||||||
|
<AdminOverview />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent value="users">
|
||||||
|
<AdminUsers />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent value="audit">
|
||||||
|
<AdminAuditLog />
|
||||||
|
</PageTabsContent>
|
||||||
|
</PageTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { AdministrationContent } from "./_components/administration-content";
|
import { AdministrationContent } from "./_components/administration-content";
|
||||||
|
|
||||||
export default async function AdministrationPage() {
|
export default async function AdministrationPage() {
|
||||||
|
const session = await getOptionalServerSessionFromHeaders();
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, session.user.id),
|
||||||
|
columns: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
description="Manage account access and platform administration"
|
description="Platform statistics, user management, and audit logging"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
|
|||||||
<AdministrationContent />
|
<AdministrationContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
dashboardGapClass,
|
||||||
|
dashboardGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -43,11 +49,10 @@ export default async function BusinessDetailPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
||||||
description="View business details and information"
|
description="View business details and information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/entities?tab=businesses">
|
<Link href="/dashboard/entities?tab=businesses">
|
||||||
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
|
|||||||
<span>Edit Business</span>
|
<span>Edit Business</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
{/* Business Information Card */}
|
{/* Business Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings & Actions Card */}
|
{/* Settings & Actions Card */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||||
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
|
import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
data={searchableBusinesses}
|
data={searchableBusinesses}
|
||||||
searchKey="searchValue"
|
searchKey="searchValue"
|
||||||
searchPlaceholder="Search by name or nickname..."
|
searchPlaceholder="Search by name or nickname..."
|
||||||
|
emptyTitle="Create your first business"
|
||||||
|
emptyDescription="Set up a business profile for invoices, branding, and tax details."
|
||||||
|
emptyIcon={<Building className="h-6 w-6" />}
|
||||||
|
emptyAction={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/businesses/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add business
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
dashboardGapClass,
|
||||||
|
dashboardGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -57,11 +63,10 @@ export default async function ClientDetailPage({
|
|||||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={client.name}
|
title={client.name}
|
||||||
description="View client details and information"
|
description="View client details and information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/entities?tab=clients">
|
<Link href="/dashboard/entities?tab=clients">
|
||||||
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
|
|||||||
<span>Edit Client</span>
|
<span>Edit Client</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
{/* Client Information Card */}
|
{/* Client Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||||
import { UserPlus, Pencil, Trash2 } from "lucide-react";
|
import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -179,6 +179,17 @@ export function ClientsDataTable({
|
|||||||
data={clients}
|
data={clients}
|
||||||
searchKey="name"
|
searchKey="name"
|
||||||
searchPlaceholder="Search clients..."
|
searchPlaceholder="Search clients..."
|
||||||
|
emptyTitle="Create your first client"
|
||||||
|
emptyDescription="Add clients to bill them and keep contact details in one place."
|
||||||
|
emptyIcon={<Users className="h-6 w-6" />}
|
||||||
|
emptyAction={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/clients/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add client
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,32 @@
|
|||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { ClientsDataTable } from "../../clients/_components/clients-data-table";
|
||||||
import { ClientsTable } from "../../clients/_components/clients-table";
|
import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
|
||||||
import { BusinessesTable } from "../../businesses/_components/businesses-table";
|
import type { RouterOutputs } from "~/trpc/react";
|
||||||
|
|
||||||
type EntityTab = "clients" | "businesses";
|
type EntityTab = "clients" | "businesses";
|
||||||
|
|
||||||
export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
type Client = RouterOutputs["clients"]["getAll"][number];
|
||||||
|
type Business = RouterOutputs["businesses"]["getAll"][number];
|
||||||
|
|
||||||
|
export function EntitiesView({
|
||||||
|
initialTab,
|
||||||
|
clients,
|
||||||
|
businesses,
|
||||||
|
}: {
|
||||||
|
initialTab: EntityTab;
|
||||||
|
clients: Client[];
|
||||||
|
businesses: Business[];
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const tab: EntityTab =
|
const tab: EntityTab =
|
||||||
@@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
|||||||
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Entities"
|
title="Entities"
|
||||||
description="Clients you bill and businesses you send from"
|
description="Clients you bill and businesses you send from"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href={addHref}>
|
<Link href={addHref}>
|
||||||
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
|||||||
<span>{addLabel}</span>
|
<span>{addLabel}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
<PageTabs value={tab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="clients">Clients</TabsTrigger>
|
<PageTabsTrigger value="clients">Clients</PageTabsTrigger>
|
||||||
<TabsTrigger value="businesses">Businesses</TabsTrigger>
|
<PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
<TabsContent value="clients" className="mt-6">
|
<PageTabsContent value="clients">
|
||||||
<ClientsTable />
|
{tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="businesses" className="mt-6">
|
<PageTabsContent value="businesses">
|
||||||
<BusinessesTable />
|
{tab === "businesses" ? (
|
||||||
</TabsContent>
|
<BusinessesDataTable businesses={businesses} />
|
||||||
</Tabs>
|
) : null}
|
||||||
</div>
|
</PageTabsContent>
|
||||||
|
</PageTabs>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Suspense } from "react";
|
import { api } from "~/trpc/server";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { EntitiesView } from "./_components/entities-view";
|
import { EntitiesView } from "./_components/entities-view";
|
||||||
|
|
||||||
export default async function EntitiesPage({
|
export default async function EntitiesPage({
|
||||||
@@ -11,16 +10,18 @@ export default async function EntitiesPage({
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
||||||
|
|
||||||
void api.clients.getAll.prefetch();
|
const [clients, businesses] = await Promise.all([
|
||||||
void api.businesses.getAll.prefetch();
|
api.clients.getAll(),
|
||||||
|
api.businesses.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<HydrateClient>
|
<EntitiesView
|
||||||
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
|
initialTab={initialTab}
|
||||||
<EntitiesView initialTab={initialTab} />
|
clients={clients}
|
||||||
</Suspense>
|
businesses={businesses}
|
||||||
</HydrateClient>
|
/>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+657
-256
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,27 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
dashboardGapClass,
|
||||||
|
dashboardGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
export function InvoiceDetailsSkeleton() {
|
export function InvoiceDetailsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<DashboardPage className="pb-24">
|
||||||
{/* Header */}
|
<DashboardPageHeader
|
||||||
<PageHeader
|
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||||
<Skeleton className="h-10 w-24" />
|
<Skeleton className="h-10 w-24" />
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{/* Content */}
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||||
{/* Left Column */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
{/* Invoice Header Skeleton */}
|
{/* Invoice Header Skeleton */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
<Card className="lg:sticky lg:top-6">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DataTable } from "~/components/data/data-table";
|
import { DataTable } from "~/components/data/data-table";
|
||||||
|
import {
|
||||||
|
formatLineItemDetail,
|
||||||
|
isFixedLineItem,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<p className="font-medium">{item.description}</p>
|
<p className="font-medium">{item.description}</p>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{formatDate(item.date)} · {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",
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
|
|
||||||
interface InvoiceTimerCardProps {
|
interface InvoiceTimerCardProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
|
|||||||
|
|
||||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<TimeClockPanel
|
||||||
<TimeClockPanel
|
compact
|
||||||
compact
|
defaultClientId={clientId}
|
||||||
defaultClientId={clientId}
|
defaultInvoiceId={invoiceId}
|
||||||
defaultInvoiceId={invoiceId}
|
/>
|
||||||
/>
|
|
||||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
|
||||||
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
|
|
||||||
Open full time clock
|
|
||||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PDFDownloadButton({
|
|||||||
{ id: invoiceId },
|
{ id: invoiceId },
|
||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
|
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,11 +59,13 @@ export function PDFDownloadButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
await generateInvoicePDF(pdfData, {
|
await generateInvoicePDF(pdfData, {
|
||||||
pdfTemplate: platformTheme?.pdfTemplate,
|
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||||
pdfAccentColor: platformTheme?.pdfAccentColor,
|
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||||
pdfFooterText: platformTheme?.pdfFooterText,
|
pdfFontFamily: pdfSettings?.pdfFontFamily,
|
||||||
pdfShowLogo: platformTheme?.pdfShowLogo,
|
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
|
||||||
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
|
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||||
|
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||||
});
|
});
|
||||||
toast.success("PDF downloaded successfully");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
|
import { api } from "~/trpc/server";
|
||||||
|
|
||||||
export default function InvoiceFormPage() {
|
interface EditInvoicePageProps {
|
||||||
const params = useParams();
|
params: Promise<{ id: string }>;
|
||||||
const id = params.id as string;
|
}
|
||||||
|
|
||||||
|
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoice = await api.invoices.getById({ id });
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
redirect("/dashboard/invoices");
|
||||||
|
}
|
||||||
|
|
||||||
// Pass the actual id, let the form component handle the logic
|
|
||||||
return <InvoiceForm invoiceId={id} />;
|
return <InvoiceForm invoiceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, useParams, useRouter } from "next/navigation";
|
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
dashboardGapClass,
|
||||||
|
dashboardGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
@@ -83,6 +89,7 @@ function daysSince(date: Date) {
|
|||||||
|
|
||||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||||
const [reminderOpen, setReminderOpen] = useState(false);
|
const [reminderOpen, setReminderOpen] = useState(false);
|
||||||
@@ -100,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("editBlocked") === "1") {
|
||||||
|
toast.error("Only draft invoices can be edited");
|
||||||
|
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
}
|
||||||
|
}, [searchParams, invoiceId, router]);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||||
@@ -224,24 +238,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-24">
|
<DashboardPage className="pb-24">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Invoice Details"
|
title="Invoice Details"
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||||
<Button asChild variant="default" className="hover-lift">
|
{storedStatus === "draft" ? (
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Button asChild variant="default" className="hover-lift">
|
||||||
<Edit className="mr-2 h-5 w-5" />
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
Edit
|
<Edit className="mr-2 h-5 w-5" />
|
||||||
</Link>
|
Edit
|
||||||
</Button>
|
</Link>
|
||||||
</PageHeader>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||||
{/* Invoice Header */}
|
{/* Invoice Header */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
@@ -531,7 +546,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
{storedStatus === "draft" && (
|
{storedStatus === "draft" && (
|
||||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||||
)}
|
)}
|
||||||
@@ -544,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Button asChild variant="secondary" className="w-full">
|
{storedStatus === "draft" ? (
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Button asChild variant="secondary" className="w-full">
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
Edit Invoice
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Edit Invoice
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||||
@@ -833,7 +850,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
@@ -17,7 +16,20 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
dashboardGapClass,
|
||||||
|
dashboardGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { EmailComposer } from "~/components/forms/email-composer";
|
import { EmailComposer } from "~/components/forms/email-composer";
|
||||||
import { EmailPreview } from "~/components/forms/email-preview";
|
import { EmailPreview } from "~/components/forms/email-preview";
|
||||||
@@ -36,21 +48,20 @@ import {
|
|||||||
|
|
||||||
function SendEmailPageSkeleton() {
|
function SendEmailPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="Loading invoice email"
|
description="Loading invoice email"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
|
||||||
<div className="bg-muted h-96 animate-pulse" />
|
<div className="bg-muted h-96 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||||
<div className="bg-muted h-64 animate-pulse" />
|
<div className="bg-muted h-64 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +291,7 @@ export default function SendEmailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
|
const fromEmail = invoice?.business?.email ?? NOREPLY_EMAIL;
|
||||||
const toEmail = invoice?.client?.email ?? "";
|
const toEmail = invoice?.client?.email ?? "";
|
||||||
|
|
||||||
const canSend =
|
const canSend =
|
||||||
@@ -292,18 +303,18 @@ export default function SendEmailPage() {
|
|||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>Invoice not found.</AlertDescription>
|
<AlertDescription>Invoice not found.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={`Send Invoice ${invoice.invoiceNumber}`}
|
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||||
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||||
"en-US",
|
"en-US",
|
||||||
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
},
|
},
|
||||||
).format(new Date())}`}
|
).format(new Date())}`}
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
|
|||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Invoice
|
Back to Invoice
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{/* Warning for missing email */}
|
{/* Warning for missing email */}
|
||||||
{(!toEmail || toEmail.trim() === "") && (
|
{(!toEmail || toEmail.trim() === "") && (
|
||||||
@@ -336,23 +346,22 @@ export default function SendEmailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<PageTabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="compose" className="flex items-center gap-2">
|
<PageTabsTrigger value="compose" className="gap-2">
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
Compose
|
Compose
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
<PageTabsTrigger value="preview" className="gap-2">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Preview
|
Preview
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
<div className="mt-6">
|
<PageTabsContent value="compose">
|
||||||
<TabsContent value="compose" className="space-y-6">
|
<Card>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Mail className="h-5 w-5" />
|
<Mail className="h-5 w-5" />
|
||||||
@@ -387,10 +396,10 @@ export default function SendEmailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="preview" className="space-y-6">
|
<PageTabsContent value="preview">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Eye className="h-5 w-5" />
|
<Eye className="h-5 w-5" />
|
||||||
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</div>
|
</PageTabs>
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||||
{/* Invoice Summary */}
|
{/* Invoice Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Send,
|
Send,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
{invoice.status === "draft" ? (
|
||||||
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hover-scale h-8 w-8 p-0"
|
||||||
|
data-action-button="true"
|
||||||
|
title="Edit invoice"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="hover-scale h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
|
disabled
|
||||||
|
title="Only draft invoices can be edited"
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
searchPlaceholder="Search invoices..."
|
searchPlaceholder="Search invoices..."
|
||||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||||
filterableColumns={filterableColumns}
|
filterableColumns={filterableColumns}
|
||||||
|
emptyTitle="Create your first invoice"
|
||||||
|
emptyDescription="Send professional invoices and track payments from one place."
|
||||||
|
emptyIcon={<FileText className="h-6 w-6" />}
|
||||||
|
emptyAction={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/invoices/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create invoice
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onRowClick={(invoice) =>
|
onRowClick={(invoice) =>
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`)
|
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +1,5 @@
|
|||||||
import {
|
import { redirect } from "next/navigation";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
CheckCircle,
|
|
||||||
Download,
|
|
||||||
FileSpreadsheet,
|
|
||||||
FileText,
|
|
||||||
Info,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { CSVImportPage } from "~/components/csv-import-page";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
|
||||||
|
|
||||||
// File Upload Instructions Component
|
export default function ImportPage() {
|
||||||
function FormatInstructions() {
|
redirect("/dashboard/settings?tab=data");
|
||||||
return (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* Required Format */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
|
||||||
<FileText className="text-primary h-5 w-5" />
|
|
||||||
Required CSV Format
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="bg-muted/50 p-4">
|
|
||||||
<p className="text-muted-foreground font-mono text-sm">
|
|
||||||
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="font-semibold">Required Columns:</h4>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{[
|
|
||||||
{ field: "DATE", desc: "Date of work (M/DD/YY format)" },
|
|
||||||
{ field: "DESCRIPTION", desc: "Description of work performed" },
|
|
||||||
{ field: "HOURS", desc: "Number of hours worked" },
|
|
||||||
{ field: "RATE", desc: "Hourly rate (decimal)" },
|
|
||||||
{
|
|
||||||
field: "AMOUNT",
|
|
||||||
desc: "Total amount (calculated from hours × rate)",
|
|
||||||
},
|
|
||||||
].map((col) => (
|
|
||||||
<div key={col.field} className="flex items-start gap-3">
|
|
||||||
<Badge className="border text-xs">{col.field}</Badge>
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
{col.desc}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<h4 className="mb-2 font-semibold">File Naming:</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Name your CSV files in{" "}
|
|
||||||
<code className="bg-muted rounded px-1 text-xs">
|
|
||||||
YYYY-MM-DD.csv
|
|
||||||
</code>{" "}
|
|
||||||
format for automatic date detection.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sample Data & Download */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
|
||||||
<Download className="text-primary h-5 w-5" />
|
|
||||||
Sample Template
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Download our sample CSV template to see the exact format required
|
|
||||||
for importing time entries.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-primary/10 p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Info className="text-primary mt-0.5 h-5 w-5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-success text-sm font-medium">Pro Tip</p>
|
|
||||||
<p className="text-success text-sm">
|
|
||||||
The template includes sample data and formatting examples to
|
|
||||||
help you get started quickly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
|
||||||
<div className="bg-muted/50 p-3">
|
|
||||||
<p className="text-muted font-mono text-xs break-all">
|
|
||||||
1/15/24,"Web development work",8,75.00,600.00
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
|
||||||
<div className="bg-muted/50 p-3">
|
|
||||||
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Important Notes Section
|
|
||||||
function ImportantNotes() {
|
|
||||||
return (
|
|
||||||
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircle className="text-primary h-5 w-5" />
|
|
||||||
Important Notes
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 font-semibold">Before Importing:</h4>
|
|
||||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<li>• Use M/DD/YY format for dates (e.g., 1/15/24)</li>
|
|
||||||
<li>• Ensure rates are in decimal format (e.g., 75.50)</li>
|
|
||||||
<li>• File names should follow YYYY-MM-DD.csv format</li>
|
|
||||||
<li>• Select a client before importing</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 font-semibold">What Happens:</h4>
|
|
||||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<li>• Each CSV file creates one invoice</li>
|
|
||||||
<li>• Invoice dates are derived from filename</li>
|
|
||||||
<li>• Invoices are created in "draft" status</li>
|
|
||||||
<li>• You can review and edit before sending</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// File Format Help Section
|
|
||||||
function FileFormatHelp() {
|
|
||||||
return (
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
|
||||||
<FileSpreadsheet className="text-primary h-5 w-5" />
|
|
||||||
Supported File Formats
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<div className="bg-accent mx-auto w-fit p-3">
|
|
||||||
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold">CSV Files</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Comma-separated values from Excel, Google Sheets, or any CSV
|
|
||||||
editor
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
|
||||||
<Upload className="text-primary h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold">Max Size</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Up to 10MB per file with no limit on number of rows
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<div className="bg-secondary mx-auto w-fit p-3">
|
|
||||||
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold">Validation</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Real-time validation with clear error messages and feedback
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ImportPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<PageHeader
|
|
||||||
title="Import Time Entries"
|
|
||||||
description="Upload CSV files to create invoices from your time tracking data"
|
|
||||||
variant="gradient"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices">
|
|
||||||
<Button variant="outline" size="lg">
|
|
||||||
<ArrowLeft className="mr-2 h-5 w-5" />
|
|
||||||
Back to Invoices
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
{/* Main CSV Import Component */}
|
|
||||||
<CSVImportPage />
|
|
||||||
|
|
||||||
{/* File Format Help */}
|
|
||||||
<FileFormatHelp />
|
|
||||||
|
|
||||||
{/* Format Instructions */}
|
|
||||||
<FormatInstructions />
|
|
||||||
|
|
||||||
{/* Important Notes */}
|
|
||||||
<ImportantNotes />
|
|
||||||
</HydrateClient>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import Link from "next/link";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { FileText, Plus, Upload } from "lucide-react";
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
|
||||||
@@ -16,37 +17,24 @@ async function InvoicesTable() {
|
|||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
description="Manage your invoices and track payments"
|
description="Manage your invoices and track payments"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
|
||||||
<Link href="/dashboard/invoices/import">
|
|
||||||
<Upload className="mr-2 h-5 w-5" />
|
|
||||||
<span>Import CSV</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
|
||||||
<Link href="/dashboard/invoices/new?blank=1">
|
|
||||||
<FileText className="mr-2 h-5 w-5" />
|
|
||||||
<span>Blank invoice</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Create Invoice</span>
|
<span>Create Invoice</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
@@ -358,17 +360,16 @@ export default function RecurringInvoicesPage() {
|
|||||||
const isSubmitting = create.isPending || update.isPending;
|
const isSubmitting = create.isPending || update.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-24">
|
<DashboardPage className="pb-24">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Recurring Invoices"
|
title="Recurring Invoices"
|
||||||
description="Schedule automatic invoice generation"
|
description="Schedule automatic invoice generation"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New recurring
|
New recurring
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-48 items-center justify-center">
|
<div className="flex h-48 items-center justify-center">
|
||||||
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (recurring ?? []).length === 0 ? (
|
) : (recurring ?? []).length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
<CardContent className="p-0">
|
||||||
<RefreshCw className="text-muted-foreground h-10 w-10" />
|
<EmptyState
|
||||||
<p className="text-muted-foreground text-sm">
|
icon={<RefreshCw className="h-6 w-6" />}
|
||||||
No recurring invoices yet. Create one to automatically generate draft invoices on a
|
title="Create your first recurring invoice"
|
||||||
schedule.
|
description="Automatically generate draft invoices on a schedule you choose."
|
||||||
</p>
|
action={
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create first recurring invoice
|
Create recurring invoice
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -529,6 +532,6 @@ export default function RecurringInvoicesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, type RouterOutputs } from "~/trpc/react";
|
import { api, type RouterOutputs } from "~/trpc/react";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -18,7 +25,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||||
|
|
||||||
@@ -187,25 +194,24 @@ export default function TemplatesPage() {
|
|||||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<DashboardPage className="pb-6">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Invoice Templates"
|
title="Invoice Templates"
|
||||||
description="Reusable notes and payment terms for your invoices"
|
description="Reusable notes and payment terms for your invoices"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="notes">
|
<PageTabsTrigger value="notes">
|
||||||
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
||||||
{notesTemplates.length})
|
{notesTemplates.length})
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
<TabsTrigger value="terms">
|
<PageTabsTrigger value="terms">
|
||||||
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
||||||
{termsTemplates.length})
|
{termsTemplates.length})
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
<TabsContent value="notes" className="mt-4">
|
<PageTabsContent value="notes">
|
||||||
<TemplateList
|
<TemplateList
|
||||||
items={notesTemplates}
|
items={notesTemplates}
|
||||||
type="notes"
|
type="notes"
|
||||||
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={setDeleteId}
|
onDelete={setDeleteId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
<TabsContent value="terms" className="mt-4">
|
<PageTabsContent value="terms">
|
||||||
<TemplateList
|
<TemplateList
|
||||||
items={termsTemplates}
|
items={termsTemplates}
|
||||||
type="terms"
|
type="terms"
|
||||||
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={setDeleteId}
|
onDelete={setDeleteId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
|
|
||||||
{/* Create/Edit dialog */}
|
{/* Create/Edit dialog */}
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AppProviders } from "~/components/providers/app-providers";
|
import { AppProviders } from "~/components/providers/app-providers";
|
||||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||||
|
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
|
||||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -16,9 +20,22 @@ export default async function DashboardLayout({
|
|||||||
redirect("/auth/signin?callbackUrl=/dashboard");
|
redirect("/auth/signin?callbackUrl=/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, session.user.id),
|
||||||
|
columns: {
|
||||||
|
role: true,
|
||||||
|
onboardingCompletedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const needsOnboarding = user?.onboardingCompletedAt == null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<DashboardShell>{children}</DashboardShell>
|
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
|
||||||
|
<DashboardShell>{children}</DashboardShell>
|
||||||
|
</DashboardUserProvider>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function OnboardingShell({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-8 space-y-4 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Logo size="lg" animated={false} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const ONBOARDING_STEPS = [
|
||||||
|
{ id: "welcome", label: "Welcome" },
|
||||||
|
{ id: "business", label: "Business" },
|
||||||
|
{ id: "client", label: "Client" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OnboardingStepId = (typeof ONBOARDING_STEPS)[number]["id"] | "done";
|
||||||
|
|
||||||
|
function stepIndex(step: OnboardingStepId) {
|
||||||
|
if (step === "done") return ONBOARDING_STEPS.length;
|
||||||
|
return ONBOARDING_STEPS.findIndex((item) => item.id === step);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_GRID_COLUMNS = ONBOARDING_STEPS.map((_, index) =>
|
||||||
|
index < ONBOARDING_STEPS.length - 1 ? "auto 1fr" : "auto",
|
||||||
|
).join(" ");
|
||||||
|
|
||||||
|
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
|
||||||
|
const currentIndex = stepIndex(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Setup progress" className="mb-8">
|
||||||
|
<ol className="sr-only">
|
||||||
|
{ONBOARDING_STEPS.map((item, index) => {
|
||||||
|
const isCurrent = currentIndex === index;
|
||||||
|
return (
|
||||||
|
<li key={item.id} aria-current={isCurrent ? "step" : undefined}>
|
||||||
|
{item.label}
|
||||||
|
{isCurrent ? " (current)" : ""}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Row 1: circles + connectors. Row 2: labels (same columns as circles). */}
|
||||||
|
<div
|
||||||
|
className="mx-auto grid w-full max-w-md items-center gap-y-2"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: TRACK_GRID_COLUMNS,
|
||||||
|
gridTemplateRows: "auto auto",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{ONBOARDING_STEPS.map((item, index) => {
|
||||||
|
const isComplete = currentIndex > index;
|
||||||
|
const isCurrent = currentIndex === index;
|
||||||
|
const isUpcoming = currentIndex < index;
|
||||||
|
const connectorComplete = currentIndex > index;
|
||||||
|
const circleCol = index * 2 + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="contents">
|
||||||
|
{index > 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-0.5 self-center rounded-full transition-colors",
|
||||||
|
connectorComplete ? "bg-primary" : "bg-border/80",
|
||||||
|
)}
|
||||||
|
style={{ gridColumn: index * 2, gridRow: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center justify-self-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||||
|
isComplete &&
|
||||||
|
"border-primary bg-primary text-primary-foreground",
|
||||||
|
isCurrent &&
|
||||||
|
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
|
||||||
|
isUpcoming &&
|
||||||
|
"border-border/80 bg-background/60 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
style={{ gridColumn: circleCol, gridRow: 1 }}
|
||||||
|
>
|
||||||
|
{isComplete ? (
|
||||||
|
<Check className="h-4 w-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden min-w-0 justify-self-center text-center text-xs leading-tight font-medium sm:block",
|
||||||
|
isCurrent ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
style={{ gridColumn: circleCol, gridRow: 2 }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
|
||||||
|
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
|
||||||
|
{ONBOARDING_STEPS.length}
|
||||||
|
{step !== "done" && ONBOARDING_STEPS[currentIndex]
|
||||||
|
? ` · ${ONBOARDING_STEPS[currentIndex].label}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
OnboardingStepIndicator,
|
||||||
|
type OnboardingStepId,
|
||||||
|
} from "./onboarding-step-indicator";
|
||||||
|
|
||||||
|
type Step = OnboardingStepId;
|
||||||
|
|
||||||
|
function StepIcon({
|
||||||
|
icon: Icon,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/10 text-primary mb-5 inline-flex rounded-2xl p-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingPanel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
marketingSurfaceClass,
|
||||||
|
"bg-card/80 px-6 py-8 sm:px-8 sm:py-10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingWizard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("welcome");
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Business added");
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
setStep("client");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createClient = api.clients.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Client added");
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
setStep("done");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeOnboarding = api.settings.completeOnboarding.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.completed) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [status?.completed, router]);
|
||||||
|
|
||||||
|
const displayStep = useMemo((): Step => {
|
||||||
|
if (step !== "welcome" || !status || status.completed) {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
if (status.businessCount > 0 && status.clientCount > 0) {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
if (status.businessCount > 0) {
|
||||||
|
return "client";
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}, [step, status]);
|
||||||
|
|
||||||
|
function handleSkip() {
|
||||||
|
completeOnboarding.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBusinessSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!businessName.trim()) {
|
||||||
|
toast.error("Business name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createBusiness.mutate({
|
||||||
|
name: businessName.trim(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClientSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!clientName.trim()) {
|
||||||
|
toast.error("Client name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createClient.mutate({ name: clientName.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinish() {
|
||||||
|
completeOnboarding.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateInvoice() {
|
||||||
|
completeOnboarding.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/dashboard/invoices/new");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || status?.completed) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
|
||||||
|
|
||||||
|
{displayStep === "welcome" && (
|
||||||
|
<OnboardingPanel>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
|
||||||
|
Quick setup
|
||||||
|
</p>
|
||||||
|
<StepIcon icon={FileText} />
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Welcome to {brand.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
Let's set up the basics so you can send your first invoice.
|
||||||
|
This only takes a minute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-8 space-y-4">
|
||||||
|
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||||
|
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Add your business</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
|
||||||
|
The name and details that appear on invoices you send.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||||
|
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Add your first client</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
|
||||||
|
Who you're billing — you can add more details later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button className="h-11 flex-1" size="lg" onClick={() => setStep("business")}>
|
||||||
|
Get started
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={completeOnboarding.isPending}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStep === "business" && (
|
||||||
|
<OnboardingPanel>
|
||||||
|
<div className="text-center">
|
||||||
|
<StepIcon icon={Building2} />
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Your business
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
This appears on invoices as the sender — name, logo, and contact
|
||||||
|
details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleBusinessSubmit} className="mt-8 space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="business-name">Business name</Label>
|
||||||
|
<Input
|
||||||
|
id="business-name"
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
placeholder="Acme Studio LLC"
|
||||||
|
className="h-11"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={createBusiness.isPending}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStep === "client" && (
|
||||||
|
<OnboardingPanel>
|
||||||
|
<div className="text-center">
|
||||||
|
<StepIcon icon={Users} />
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Your first client
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
Who are you billing? You can add more details later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="client-name">Client name</Label>
|
||||||
|
<Input
|
||||||
|
id="client-name"
|
||||||
|
value={clientName}
|
||||||
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
className="h-11"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={createClient.isPending}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStep === "done" && (
|
||||||
|
<OnboardingPanel className="text-center">
|
||||||
|
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
|
||||||
|
<CheckCircle2 className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
You're ready to go
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
Your workspace is set up. Create an invoice or explore the dashboard.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
|
||||||
|
Go to dashboard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
onClick={handleCreateInvoice}
|
||||||
|
>
|
||||||
|
Create first invoice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step !== "welcome" && displayStep !== "done" && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
setStep(displayStep === "client" ? "business" : "welcome")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { OnboardingShell } from "./_components/onboarding-shell";
|
||||||
|
import { OnboardingWizard } from "./_components/onboarding-wizard";
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
return (
|
||||||
|
<OnboardingShell>
|
||||||
|
<OnboardingWizard />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+190
-308
@@ -10,24 +10,34 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||||
|
import {
|
||||||
|
InvoiceStatusChart,
|
||||||
|
MonthlyMetricsChart,
|
||||||
|
RevenueChart,
|
||||||
|
} from "~/app/dashboard/_components/charts-client";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import {
|
||||||
|
DashboardCardTitle,
|
||||||
|
DashboardGrid,
|
||||||
|
DashboardPage as DashboardPageLayout,
|
||||||
|
dashboardGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import {
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
Card,
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
import { HydrateClient, api } from "~/trpc/server";
|
import { cn } from "~/lib/utils";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import { api } from "~/trpc/server";
|
||||||
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
|
||||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
|
||||||
import type { DashboardStats, RecentInvoice } from "./types";
|
import type { DashboardStats, RecentInvoice } from "./types";
|
||||||
|
|
||||||
// Hero section with clean mono design
|
|
||||||
|
|
||||||
// Enhanced stats cards with better visuals
|
|
||||||
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||||
// TODO: Import RouterOutput type
|
|
||||||
const formatTrend = (value: number, isCount = false) => {
|
const formatTrend = (value: number, isCount = false) => {
|
||||||
if (isCount) {
|
if (isCount) {
|
||||||
return value > 0 ? `+${value}` : value.toString();
|
return value > 0 ? `+${value}` : value.toString();
|
||||||
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
|||||||
change: formatTrend(stats.revenueChange),
|
change: formatTrend(stats.revenueChange),
|
||||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||||
iconName: "DollarSign" as const,
|
iconName: "DollarSign" as const,
|
||||||
description: "Total collected revenue",
|
description: "Collected to date",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pending Amount",
|
title: "Pending",
|
||||||
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
numericValue: stats.pendingAmount,
|
numericValue: stats.pendingAmount,
|
||||||
isCurrency: true,
|
isCurrency: true,
|
||||||
change: "0%", // TODO: Calculate pending change if needed
|
change: "0%",
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
iconName: "Clock" as const,
|
iconName: "Clock" as const,
|
||||||
description: "Invoices awaiting payment",
|
description: "Awaiting payment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Clients",
|
title: "Clients",
|
||||||
value: stats.totalClients.toString(),
|
value: stats.totalClients.toString(),
|
||||||
numericValue: stats.totalClients,
|
numericValue: stats.totalClients,
|
||||||
isCurrency: false,
|
isCurrency: false,
|
||||||
change: "0", // TODO: Calculate client change if needed
|
change: "0",
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
iconName: "Users" as const,
|
iconName: "Users" as const,
|
||||||
description: "Total registered clients",
|
description: "Active clients",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overdue Invoices",
|
title: "Overdue",
|
||||||
value: stats.overdueCount.toString(),
|
value: stats.overdueCount.toString(),
|
||||||
numericValue: stats.overdueCount,
|
numericValue: stats.overdueCount,
|
||||||
isCurrency: false,
|
isCurrency: false,
|
||||||
change: "0", // TODO: Calculate overdue change if needed
|
change: "0",
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
iconName: "TrendingDown" as const,
|
iconName: "TrendingDown" as const,
|
||||||
description: "Invoices past due date",
|
description: "Past due date",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className={cn(dashboardGridClass, "sm:grid-cols-2 xl:grid-cols-4")}>
|
||||||
{statCards.map((stat, index) => (
|
{statCards.map((stat, index) => (
|
||||||
<AnimatedStatsCard
|
<AnimatedStatsCard
|
||||||
key={stat.title}
|
key={stat.title}
|
||||||
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charts section
|
function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||||
async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
|
||||||
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
|
|
||||||
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
|
|
||||||
// Actually, let's fetch invoices here for the status chart to keep it working.
|
|
||||||
const invoices = await api.invoices.getAll();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<DashboardGrid className="lg:grid-cols-2">
|
||||||
{/* Revenue Trend Chart */}
|
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<BarChart3 className="h-5 w-5" />
|
<DashboardCardTitle icon={BarChart3}>
|
||||||
Revenue Over Time
|
Revenue over time
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Invoice Status Breakdown */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Activity className="h-5 w-5" />
|
<DashboardCardTitle icon={Activity}>
|
||||||
Invoice Status
|
Invoice status
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<InvoiceStatusChart invoices={invoices} />
|
<InvoiceStatusChart data={stats.statusChartData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Monthly Metrics */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Calendar className="h-5 w-5" />
|
<DashboardCardTitle icon={Calendar}>
|
||||||
Monthly Metrics
|
Monthly metrics
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<MonthlyMetricsChart invoices={invoices} />
|
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</DashboardGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced Quick Actions
|
|
||||||
function QuickActions() {
|
function QuickActions() {
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
title: "Create Invoice",
|
title: "Create invoice",
|
||||||
description: "Start a new invoice for a client",
|
description: "Start a new invoice for a client",
|
||||||
href: "/dashboard/invoices/new",
|
href: "/dashboard/invoices/new",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
featured: true,
|
featured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Add Client",
|
title: "Add client",
|
||||||
description: "Register a new client",
|
description: "Register someone you bill",
|
||||||
href: "/dashboard/clients/new",
|
href: "/dashboard/clients/new",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
featured: false,
|
featured: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "View All Invoices",
|
title: "View invoices",
|
||||||
description: "Manage your invoice pipeline",
|
description: "Browse your full pipeline",
|
||||||
href: "/dashboard/invoices",
|
href: "/dashboard/invoices",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
featured: false,
|
featured: false,
|
||||||
@@ -178,27 +181,36 @@ function QuickActions() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Plus className="h-5 w-5" />
|
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
|
||||||
Quick Actions
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-2">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={action.title}
|
key={action.title}
|
||||||
href={action.href}
|
href={action.href}
|
||||||
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
|
className={cn(
|
||||||
|
"flex items-start gap-3 rounded-2xl border p-4 transition-colors",
|
||||||
action.featured
|
action.featured
|
||||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
: "border-border/60 bg-background/50 hover:bg-muted/50",
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl p-2",
|
||||||
|
action.featured
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-semibold">{action.title}</p>
|
<p className="text-sm font-medium">{action.title}</p>
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
{action.description}
|
{action.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -211,204 +223,168 @@ function QuickActions() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current work section with enhanced design
|
function CurrentWork({
|
||||||
async function CurrentWork() {
|
currentDraft,
|
||||||
const invoices = await api.invoices.getAll();
|
}: {
|
||||||
const draftInvoices = invoices.filter(
|
currentDraft: DashboardStats["currentDraft"];
|
||||||
(invoice) =>
|
}) {
|
||||||
getEffectiveInvoiceStatus(
|
if (!currentDraft) {
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
) === "draft",
|
|
||||||
);
|
|
||||||
const currentInvoice = draftInvoices[0];
|
|
||||||
|
|
||||||
if (!currentInvoice) {
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Activity className="h-5 w-5" />
|
<DashboardCardTitle icon={Activity}>
|
||||||
Current Work
|
Current work
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col items-center py-8 text-center">
|
||||||
<div className="py-8 text-center">
|
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<FileText className="text-muted-foreground h-6 w-6" />
|
||||||
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
Create a new invoice to get started
|
|
||||||
</p>
|
|
||||||
<Button asChild variant="outline" className="border-foreground/20">
|
|
||||||
<Link href="/dashboard/invoices/new">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Invoice
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="font-medium">No draft in progress</p>
|
||||||
|
<CardDescription className="mt-1 max-w-xs">
|
||||||
|
Start an invoice when you're ready to bill your next piece of
|
||||||
|
work.
|
||||||
|
</CardDescription>
|
||||||
|
<Button asChild variant="outline" className="mt-5">
|
||||||
|
<Link href="/dashboard/invoices/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create invoice
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalHours =
|
const totalHours = currentDraft.totalHours;
|
||||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Activity className="h-5 w-5" />
|
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
|
||||||
Current Work
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">In Progress</Badge>
|
<Badge variant="secondary">Draft</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<h3 className="text-lg font-semibold break-words">
|
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
|
||||||
#{currentInvoice.invoiceNumber}
|
<p className="text-muted-foreground text-sm">
|
||||||
</h3>
|
{currentDraft.client?.name}
|
||||||
<span className="text-primary text-2xl font-bold">
|
</p>
|
||||||
${currentInvoice.totalAmount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span className="break-words">{currentInvoice.client?.name}</span>
|
|
||||||
<span className="text-xs sm:text-sm">
|
|
||||||
{totalHours.toFixed(1)} hours logged
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||||
|
${currentDraft.totalAmount.toFixed(2)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||||
|
{totalHours.toFixed(1)} hours logged
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||||
asChild
|
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
|
||||||
variant="outline"
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
size="sm"
|
View
|
||||||
className="hover-lift flex-1"
|
</Link>
|
||||||
>
|
</Button>
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
<Button asChild size="sm" className="flex-1">
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
|
||||||
View
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Continue
|
||||||
</Button>
|
</Link>
|
||||||
<Button asChild size="sm" className="hover-lift flex-1">
|
</Button>
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Continue
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced recent activity
|
function RecentActivity({
|
||||||
async function RecentActivity({
|
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
}: {
|
}: {
|
||||||
recentInvoices: RecentInvoice[];
|
recentInvoices: RecentInvoice[];
|
||||||
}) {
|
}) {
|
||||||
// Use passed recentInvoices instead of fetching all
|
const getStatusVariant = (status: string) => {
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return {
|
return "default" as const;
|
||||||
backgroundColor: "oklch(var(--chart-2) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-2) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-2))",
|
|
||||||
};
|
|
||||||
case "sent":
|
case "sent":
|
||||||
return {
|
return "secondary" as const;
|
||||||
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-1) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-1))",
|
|
||||||
};
|
|
||||||
case "overdue":
|
case "overdue":
|
||||||
return {
|
return "destructive" as const;
|
||||||
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-3) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-3))",
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {
|
return "outline" as const;
|
||||||
backgroundColor: "hsl(var(--muted))",
|
|
||||||
borderColor: "hsl(var(--border))",
|
|
||||||
color: "hsl(var(--muted-foreground))",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="h-full">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Calendar className="h-5 w-5" />
|
<DashboardCardTitle icon={Calendar}>
|
||||||
Recent Activity
|
Recent activity
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/dashboard/invoices">
|
<Link href="/dashboard/invoices">
|
||||||
<span className="hidden sm:inline">View All</span>
|
<span className="hidden sm:inline">View all</span>
|
||||||
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<div className="py-8 text-center">
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||||
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
<FileText className="text-muted-foreground h-6 w-6" />
|
||||||
<p className="text-muted-foreground mb-4">
|
</div>
|
||||||
Create your first invoice to get started
|
<p className="font-medium">No invoices yet</p>
|
||||||
</p>
|
<CardDescription className="mt-1 max-w-xs">
|
||||||
<Button asChild variant="outline" className="border-foreground/20">
|
Your latest invoices will show up here.
|
||||||
|
</CardDescription>
|
||||||
|
<Button asChild variant="outline" className="mt-5">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Your First Invoice
|
Create invoice
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{recentInvoices.map((invoice, _index) => (
|
{recentInvoices.map((invoice) => (
|
||||||
<Link
|
<Link
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
href={`/dashboard/invoices/${invoice.id}`}
|
href={`/dashboard/invoices/${invoice.id}`}
|
||||||
className="block"
|
className="hover:bg-muted/50 border-border/60 flex items-center gap-3 rounded-2xl border p-3 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
<div className="bg-muted rounded-xl p-2">
|
||||||
<div className="flex items-start gap-3">
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
</div>
|
||||||
<FileText className="text-muted-foreground h-4 w-4" />
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<p className="truncate text-sm font-medium">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
#{invoice.invoiceNumber}
|
||||||
<div className="min-w-0">
|
</p>
|
||||||
<p className="truncate font-medium">
|
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||||
#{invoice.invoiceNumber}
|
${invoice.totalAmount.toFixed(2)}
|
||||||
</p>
|
</span>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
</div>
|
||||||
{invoice.client?.name}
|
<div className="mt-1 flex items-center justify-between gap-2">
|
||||||
</p>
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
</div>
|
{invoice.client?.name}
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
</p>
|
||||||
<Badge style={getStatusStyle(invoice.status)}>
|
<Badge
|
||||||
{invoice.status}
|
variant={getStatusVariant(invoice.status)}
|
||||||
</Badge>
|
className="shrink-0 text-[10px]"
|
||||||
<span className="text-primary font-semibold">
|
>
|
||||||
${invoice.totalAmount.toFixed(2)}
|
{invoice.status}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -420,121 +396,27 @@ async function RecentActivity({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading skeletons
|
|
||||||
function StatsSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-4 w-12" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="mb-2 h-8 w-20" />
|
|
||||||
<Skeleton className="h-3 w-32" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartsSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-40" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-36" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardSkeleton() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
|
||||||
|
|
||||||
// ... imports
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await getOptionalServerSessionFromHeaders();
|
const session = await getOptionalServerSessionFromHeaders();
|
||||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||||
|
|
||||||
// Fetch stats centrally
|
|
||||||
const stats = await api.dashboard.getStats();
|
const stats = await api.dashboard.getStats();
|
||||||
void api.timeEntries.getRunning.prefetch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPageLayout>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title={`Welcome back, ${firstName}!`}
|
title={`Welcome back, ${firstName}`}
|
||||||
description="Here's what's happening with your business today"
|
description="A snapshot of your invoices, revenue, and work in progress."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<DashboardStats stats={stats} />
|
||||||
<Suspense fallback={<StatsSkeleton />}>
|
<ChartsSection stats={stats} />
|
||||||
<DashboardStats stats={stats} />
|
<DashboardGrid className="lg:grid-cols-2">
|
||||||
</Suspense>
|
<div className={cn(dashboardGridClass)}>
|
||||||
</HydrateClient>
|
<CurrentWork currentDraft={stats.currentDraft} />
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<ChartsSkeleton />}>
|
|
||||||
<ChartsSection stats={stats} />
|
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
|
||||||
<CurrentWork />
|
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||||
<HydrateClient>
|
</DashboardGrid>
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
</DashboardPageLayout>
|
||||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -14,7 +15,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
import { formatCurrency } from "~/lib/currency";
|
import { formatCurrency } from "~/lib/currency";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
@@ -45,10 +51,14 @@ function toNumericChartValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
|
const [businessFilter, setBusinessFilter] = useState("all");
|
||||||
|
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||||
const { data: invoices = [], isLoading: invoicesLoading } =
|
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||||
api.invoices.getAll.useQuery();
|
api.invoices.getAll.useQuery();
|
||||||
const { data: expenses = [], isLoading: expensesLoading } =
|
const { data: expenses = [], isLoading: expensesLoading } =
|
||||||
api.expenses.getAll.useQuery();
|
api.expenses.getAll.useQuery(
|
||||||
|
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||||
|
);
|
||||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
const isLoading = invoicesLoading || expensesLoading;
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
@@ -56,9 +66,14 @@ export default function ReportsPage() {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
|
const filteredInvoices = useMemo(() => {
|
||||||
|
if (businessFilter === "all") return invoices;
|
||||||
|
return invoices.filter((inv) => inv.businessId === businessFilter);
|
||||||
|
}, [invoices, businessFilter]);
|
||||||
|
|
||||||
// Overview data (last 12 months)
|
// Overview data (last 12 months)
|
||||||
const overviewData = useMemo(() => {
|
const overviewData = useMemo(() => {
|
||||||
if (!invoices.length) return null;
|
if (!filteredInvoices.length) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monthMap: Record<string, number> = {};
|
const monthMap: Record<string, number> = {};
|
||||||
@@ -72,7 +87,7 @@ export default function ReportsPage() {
|
|||||||
let totalPending = 0;
|
let totalPending = 0;
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
|
|
||||||
for (const inv of invoices) {
|
for (const inv of filteredInvoices) {
|
||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -96,7 +111,7 @@ export default function ReportsPage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
for (const inv of invoices) {
|
for (const inv of filteredInvoices) {
|
||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -120,7 +135,7 @@ export default function ReportsPage() {
|
|||||||
paid: 0,
|
paid: 0,
|
||||||
overdue: 0,
|
overdue: 0,
|
||||||
};
|
};
|
||||||
for (const inv of invoices) {
|
for (const inv of filteredInvoices) {
|
||||||
const s = getEffectiveInvoiceStatus(
|
const s = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -136,13 +151,13 @@ export default function ReportsPage() {
|
|||||||
totalHours,
|
totalHours,
|
||||||
statusCount,
|
statusCount,
|
||||||
};
|
};
|
||||||
}, [invoices]);
|
}, [filteredInvoices]);
|
||||||
|
|
||||||
// Tax summary for selected year
|
// Tax summary for selected year
|
||||||
const taxData = useMemo(() => {
|
const taxData = useMemo(() => {
|
||||||
const year = parseInt(taxYear);
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
const yearInvoices = invoices.filter((inv) => {
|
const yearInvoices = filteredInvoices.filter((inv) => {
|
||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -218,20 +233,20 @@ export default function ReportsPage() {
|
|||||||
yearInvoices,
|
yearInvoices,
|
||||||
yearExpenses,
|
yearExpenses,
|
||||||
};
|
};
|
||||||
}, [invoices, expenses, taxYear]);
|
}, [filteredInvoices, expenses, taxYear]);
|
||||||
|
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo(() => {
|
||||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
for (const inv of invoices)
|
for (const inv of filteredInvoices)
|
||||||
years.add(new Date(inv.issueDate).getFullYear());
|
years.add(new Date(inv.issueDate).getFullYear());
|
||||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
return Array.from(years).sort((a, b) => b - a);
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
}, [invoices, expenses, currentYear]);
|
}, [filteredInvoices, expenses, currentYear]);
|
||||||
|
|
||||||
const avgInvoice =
|
const avgInvoice =
|
||||||
invoices.length > 0
|
filteredInvoices.length > 0
|
||||||
? (overviewData?.totalRevenue ?? 0) /
|
? (overviewData?.totalRevenue ?? 0) /
|
||||||
(invoices.filter(
|
(filteredInvoices.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
i.status as StoredInvoiceStatus,
|
i.status as StoredInvoiceStatus,
|
||||||
@@ -308,42 +323,57 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Reports"
|
title="Reports"
|
||||||
description="Revenue and tax analytics"
|
description="Revenue and tax analytics"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className={dashboardStatGridClass}>
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Reports"
|
title="Reports"
|
||||||
description="Revenue and tax analytics"
|
description="Revenue and tax analytics"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<span className="text-sm font-medium">Business</span>
|
||||||
<TabsTrigger value="overview">
|
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||||
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
<SelectTrigger className="w-52">
|
||||||
</TabsTrigger>
|
<SelectValue placeholder="All businesses" />
|
||||||
<TabsTrigger value="tax">
|
</SelectTrigger>
|
||||||
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
<SelectContent>
|
||||||
</TabsTrigger>
|
<SelectItem value="all">All businesses</SelectItem>
|
||||||
</TabsList>
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PageTabs defaultValue="overview">
|
||||||
|
<PageTabsList>
|
||||||
|
<PageTabsTrigger value="overview" className="gap-1.5">
|
||||||
|
<TrendingUp className="h-4 w-4" /> Overview
|
||||||
|
</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="tax" className="gap-1.5">
|
||||||
|
<FileText className="h-4 w-4" /> Tax Summary
|
||||||
|
</PageTabsTrigger>
|
||||||
|
</PageTabsList>
|
||||||
|
|
||||||
{/* ── OVERVIEW TAB ── */}
|
{/* ── OVERVIEW TAB ── */}
|
||||||
<TabsContent value="overview" className="mt-4 space-y-6">
|
<PageTabsContent value="overview">
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className={dashboardStatGridClass}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -569,7 +599,7 @@ export default function ReportsPage() {
|
|||||||
<div
|
<div
|
||||||
className="bg-primary h-full rounded-full"
|
className="bg-primary h-full rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,7 +610,7 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{invoices.length === 0 && (
|
{filteredInvoices.length === 0 && (
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No invoices yet.
|
No invoices yet.
|
||||||
</p>
|
</p>
|
||||||
@@ -630,10 +660,10 @@ export default function ReportsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* ── TAX SUMMARY TAB ── */}
|
{/* ── TAX SUMMARY TAB ── */}
|
||||||
<TabsContent value="tax" className="mt-4 space-y-6">
|
<PageTabsContent value="tax">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium">Tax Year</span>
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
@@ -840,8 +870,8 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CircleHelp, FileJson, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ImportCsvTemplateButton,
|
||||||
|
ImportJsonTemplateButton,
|
||||||
|
} from "./import-sample-download";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
|
import { JSON_TEMPLATE } from "~/lib/invoice-import-templates";
|
||||||
|
|
||||||
|
const CSV_COLUMNS = [
|
||||||
|
{
|
||||||
|
field: "date",
|
||||||
|
required: false,
|
||||||
|
desc: "Work date (M/D/YY, YYYY-MM-DD, or ISO)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "item",
|
||||||
|
required: false,
|
||||||
|
desc: "Short item name (combined with description if both present)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "description",
|
||||||
|
required: "one of item/description",
|
||||||
|
desc: "Line item description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "quantity",
|
||||||
|
required: true,
|
||||||
|
desc: "Hours or units (aliases: hours, qty)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "rate",
|
||||||
|
required: true,
|
||||||
|
desc: "Unit rate (aliases: price, hourly rate)",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ImportFormatInfoDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<CircleHelp className="mr-2 h-4 w-4" />
|
||||||
|
Format guide
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col sm:max-w-4xl">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="text-primary h-5 w-5" />
|
||||||
|
Import format guide
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
CSV and JSON reference for bulk invoice imports. All imported
|
||||||
|
invoices are created as drafts for review.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<PageTabs defaultValue="csv" className="min-h-0 flex-1">
|
||||||
|
<PageTabsList>
|
||||||
|
<PageTabsTrigger value="csv">
|
||||||
|
<FileSpreadsheet className="mr-1.5 h-4 w-4" />
|
||||||
|
CSV
|
||||||
|
</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="json">
|
||||||
|
<FileJson className="mr-1.5 h-4 w-4" />
|
||||||
|
JSON
|
||||||
|
</PageTabsTrigger>
|
||||||
|
</PageTabsList>
|
||||||
|
|
||||||
|
<PageTabsContent
|
||||||
|
value="csv"
|
||||||
|
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
One CSV file creates one invoice. The invoice title is the
|
||||||
|
filename without the extension (e.g.{" "}
|
||||||
|
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||||
|
acme-january.csv
|
||||||
|
</code>{" "}
|
||||||
|
→ title "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,35 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
import { SettingsContent } from "./_components/settings-content";
|
import { SettingsContent } from "./_components/settings-content";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const validTabs = ["general", "preferences", "data", "api"] as const;
|
||||||
|
const initialTab = validTabs.includes(
|
||||||
|
params.tab as (typeof validTabs)[number],
|
||||||
|
)
|
||||||
|
? (params.tab as (typeof validTabs)[number])
|
||||||
|
: "general";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Manage your account preferences and data"
|
description="Manage your account preferences and data"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||||
<SettingsContent />
|
<SettingsContent initialTab={initialTab} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { TimeEntriesHistory } from "~/components/time-clock/time-entries-history";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function TimeClockEntriesPage() {
|
||||||
|
void api.timeEntries.getAll.prefetch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPage>
|
||||||
|
<DashboardPageHeader
|
||||||
|
title="Time entries"
|
||||||
|
description="Your completed time tracking history"
|
||||||
|
>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/dashboard/time-clock">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Time clock
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</DashboardPageHeader>
|
||||||
|
<HydrateClient>
|
||||||
|
<TimeEntriesHistory />
|
||||||
|
</HydrateClient>
|
||||||
|
</DashboardPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HydrateClient, api } from "~/trpc/server";
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
|
|
||||||
export default async function TimeClockPage({
|
export default async function TimeClockPage({
|
||||||
@@ -17,7 +18,7 @@ export default async function TimeClockPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title="Time clock"
|
title="Time clock"
|
||||||
description="Track billable hours and save them directly to an invoice"
|
description="Track billable hours and save them directly to an invoice"
|
||||||
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
|
|||||||
defaultInvoiceId={params.invoiceId}
|
defaultInvoiceId={params.invoiceId}
|
||||||
/>
|
/>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||||
|
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr
|
{formatLineItemDetail(
|
||||||
|
item.hours,
|
||||||
|
item.rate,
|
||||||
|
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-gray-900 shrink-0">
|
<p className="font-semibold text-gray-900 shrink-0">
|
||||||
|
|||||||
+25
-52
@@ -4,21 +4,33 @@ import { type Metadata } from "next";
|
|||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
import {
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
brand,
|
import { brand } from "~/lib/branding";
|
||||||
defaultBodyFontPreference,
|
|
||||||
defaultHeadingFontPreference,
|
|
||||||
defaultInterfaceTheme,
|
|
||||||
defaultRadiusPreference,
|
|
||||||
defaultSidebarStyle,
|
|
||||||
} from "~/lib/branding";
|
|
||||||
|
|
||||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||||
import { BrandBackground } from "~/components/layout/brand-background";
|
import { BrandBackground } from "~/components/layout/brand-background";
|
||||||
|
|
||||||
|
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${brand.name} - Invoicing Made Simple`,
|
metadataBase: new URL(getAppUrl()),
|
||||||
|
title: {
|
||||||
|
default: siteTitle,
|
||||||
|
template: `%s | ${brand.name}`,
|
||||||
|
},
|
||||||
description: brand.tagline,
|
description: brand.tagline,
|
||||||
|
openGraph: {
|
||||||
|
title: siteTitle,
|
||||||
|
description: brand.tagline,
|
||||||
|
siteName: brand.name,
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: siteTitle,
|
||||||
|
description: brand.tagline,
|
||||||
|
},
|
||||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,23 +46,6 @@ const playfair = localFont({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const frutiger = localFont({
|
|
||||||
src: [
|
|
||||||
{
|
|
||||||
path: "../../public/fonts/frutiger/Frutiger.ttf",
|
|
||||||
weight: "400",
|
|
||||||
style: "normal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
|
|
||||||
weight: "700",
|
|
||||||
style: "normal",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
variable: "--font-frutiger",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = localFont({
|
const geistMono = localFont({
|
||||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-geist-mono",
|
||||||
@@ -64,14 +59,8 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
lang="en"
|
lang="en"
|
||||||
data-interface-theme={defaultInterfaceTheme}
|
|
||||||
data-body-font={defaultBodyFontPreference}
|
|
||||||
data-heading-font={defaultHeadingFontPreference}
|
|
||||||
data-radius={defaultRadiusPreference}
|
|
||||||
data-sidebar-style={defaultSidebarStyle}
|
|
||||||
data-color-mode="system"
|
data-color-mode="system"
|
||||||
data-color-theme="slate"
|
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||||
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
|
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script
|
||||||
@@ -79,27 +68,11 @@ export default function RootLayout({
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
try {
|
try {
|
||||||
var defaults = {
|
|
||||||
interfaceTheme: "${defaultInterfaceTheme}",
|
|
||||||
bodyFontPreference: "${defaultBodyFontPreference}",
|
|
||||||
headingFontPreference: "${defaultHeadingFontPreference}",
|
|
||||||
radiusPreference: "${defaultRadiusPreference}",
|
|
||||||
sidebarStyle: "${defaultSidebarStyle}",
|
|
||||||
colorMode: "system",
|
|
||||||
colorTheme: "slate"
|
|
||||||
};
|
|
||||||
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||||
var appearance = Object.assign(defaults, stored);
|
var colorMode = stored.colorMode || "system";
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
root.dataset.interfaceTheme = appearance.interfaceTheme;
|
root.dataset.colorMode = colorMode;
|
||||||
root.dataset.bodyFont = appearance.bodyFontPreference;
|
if (colorMode === "dark") root.classList.add("dark");
|
||||||
root.dataset.headingFont = appearance.headingFontPreference;
|
|
||||||
root.dataset.radius = appearance.radiusPreference;
|
|
||||||
root.dataset.sidebarStyle = appearance.sidebarStyle;
|
|
||||||
root.dataset.colorMode = appearance.colorMode;
|
|
||||||
root.dataset.colorTheme = appearance.colorTheme;
|
|
||||||
if (appearance.colorMode === "dark") root.classList.add("dark");
|
|
||||||
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
|
|
||||||
} catch {}
|
} catch {}
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
import { brand, splitLogoText } from "~/lib/branding";
|
||||||
|
|
||||||
|
export const alt = `${brand.name} - Invoicing Made Simple`;
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(to right, rgba(128,128,128,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(128,128,128,0.07) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "24px 24px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 520,
|
||||||
|
height: 520,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "rgba(163, 163, 163, 0.25)",
|
||||||
|
filter: "blur(80px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1,
|
||||||
|
padding: "0 80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#18181b" }}>{brand.icon}</span>
|
||||||
|
<span style={{ width: 16 }} />
|
||||||
|
<span style={{ color: "#09090b" }}>{logoPrefix}</span>
|
||||||
|
<span style={{ color: "rgba(9, 9, 11, 0.7)" }}>{logoSuffix}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 32,
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#09090b",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Invoicing Made Simple
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "#71717a",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 900,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand.tagline}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...size,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { brand } from "~/lib/branding";
|
import { brand, splitLogoText } from "~/lib/branding";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
@@ -11,24 +10,8 @@ interface LogoProps {
|
|||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitLogoText(logoText: string) {
|
|
||||||
const voiceIndex = logoText.toLowerCase().indexOf("voice");
|
|
||||||
|
|
||||||
if (voiceIndex > 0) {
|
|
||||||
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
logoText.slice(0, Math.ceil(logoText.length / 2)),
|
|
||||||
logoText.slice(Math.ceil(logoText.length / 2)),
|
|
||||||
] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||||
const appearance = useAppearance();
|
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||||
const logoText = appearance.brandLogoText || brand.logoText;
|
|
||||||
const icon = appearance.brandIcon || brand.icon;
|
|
||||||
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "text-base",
|
sm: "text-base",
|
||||||
md: "text-xl",
|
md: "text-xl",
|
||||||
@@ -45,7 +28,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
sizeClasses={sizeClasses}
|
sizeClasses={sizeClasses}
|
||||||
logoPrefix={logoPrefix}
|
logoPrefix={logoPrefix}
|
||||||
logoSuffix={logoSuffix}
|
logoSuffix={logoSuffix}
|
||||||
icon={icon}
|
icon={brand.icon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,7 +50,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
||||||
className="text-primary font-bold tracking-tight"
|
className="text-primary font-bold tracking-tight"
|
||||||
>
|
>
|
||||||
{icon}
|
{brand.icon}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
{size !== "icon" && (
|
{size !== "icon" && (
|
||||||
<>
|
<>
|
||||||
@@ -75,8 +58,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
||||||
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
|
className="inline-block w-1"
|
||||||
></motion.span>
|
/>
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -125,7 +108,7 @@ function LogoContent({
|
|||||||
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
||||||
{size !== "icon" && (
|
{size !== "icon" && (
|
||||||
<>
|
<>
|
||||||
<span className="inline-block w-1"></span>
|
<span className="inline-block w-1" />
|
||||||
<span className="text-foreground font-bold tracking-tight">
|
<span className="text-foreground font-bold tracking-tight">
|
||||||
{logoPrefix}
|
{logoPrefix}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { ResponsiveContainer } from "recharts";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface ResponsiveChartProps {
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
children: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveChart({
|
||||||
|
height = 256,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: ResponsiveChartProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full min-w-0", className)}>
|
||||||
|
<ResponsiveContainer width="100%" height={height} minWidth={0}>
|
||||||
|
{children}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import {
|
|||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
Filter,
|
Filter,
|
||||||
Search,
|
Search,
|
||||||
|
SearchX,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
|
|||||||
clearSelection: () => void,
|
clearSelection: () => void,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
initialSorting?: SortingState;
|
initialSorting?: SortingState;
|
||||||
|
/** Shown when the dataset is empty (no rows in DB). */
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
emptyIcon?: React.ReactNode;
|
||||||
|
emptyAction?: React.ReactNode;
|
||||||
|
/** Shown when filters/search hide all rows but data exists. */
|
||||||
|
filteredEmptyTitle?: string;
|
||||||
|
filteredEmptyDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableEmptyStateProps {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered empty state for data tables (reuses page EmptyState). */
|
||||||
|
export function DataTableEmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: DataTableEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
action={action}
|
||||||
|
className={cn("py-16", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
selectionActions,
|
selectionActions,
|
||||||
initialSorting = [],
|
initialSorting = [],
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
emptyIcon,
|
||||||
|
emptyAction,
|
||||||
|
filteredEmptyTitle = "No matches for your search",
|
||||||
|
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
|
|||||||
}, [globalFilter]);
|
}, [globalFilter]);
|
||||||
|
|
||||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||||
|
const filteredRowCount = table.getFilteredRowModel().rows.length;
|
||||||
|
const isDatasetEmpty = data.length === 0;
|
||||||
|
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
|
||||||
|
|
||||||
// Handle row click
|
// Handle row click
|
||||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||||
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell
|
<TableCell colSpan={columns.length} className="p-0">
|
||||||
colSpan={columns.length}
|
{isDatasetEmpty && emptyTitle ? (
|
||||||
className="h-24 text-center"
|
<DataTableEmptyState
|
||||||
>
|
icon={emptyIcon}
|
||||||
<p className="text-muted-foreground">No results found</p>
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
|
/>
|
||||||
|
) : isFilteredEmpty ? (
|
||||||
|
<DataTableEmptyState
|
||||||
|
icon={<SearchX className="h-6 w-6" />}
|
||||||
|
title={filteredEmptyTitle}
|
||||||
|
description={filteredEmptyDescription}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,11 +155,22 @@ export function InvoiceList() {
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
{invoice.status === "draft" ? (
|
||||||
<Button variant="ghost" size="sm">
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
title="Only draft invoices can be edited"
|
||||||
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FileText, Loader2, Paperclip } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||||
|
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
import { isImageReceipt, receiptUrl } from "~/components/expenses/receipt-utils";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface ReceiptPreview {
|
||||||
|
id: string;
|
||||||
|
mimeType: string;
|
||||||
|
originalFilename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpenseReceiptIndicatorProps {
|
||||||
|
expenseId: string;
|
||||||
|
receiptCount: number;
|
||||||
|
receiptPreview: ReceiptPreview | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseReceiptIndicator({
|
||||||
|
expenseId,
|
||||||
|
receiptCount,
|
||||||
|
receiptPreview,
|
||||||
|
className,
|
||||||
|
}: ExpenseReceiptIndicatorProps) {
|
||||||
|
const [listOpen, setListOpen] = useState(false);
|
||||||
|
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||||
|
{ expenseId },
|
||||||
|
{ enabled: listOpen && receiptCount > 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (receiptCount === 0) {
|
||||||
|
return (
|
||||||
|
<span className={cn("text-muted-foreground text-xs", className)}>—</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (receiptCount === 1 && receiptPreview) {
|
||||||
|
setViewerReceipt({
|
||||||
|
id: receiptPreview.id,
|
||||||
|
originalFilename: receiptPreview.originalFilename,
|
||||||
|
mimeType: receiptPreview.mimeType,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setListOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewIsImage =
|
||||||
|
receiptPreview && isImageReceipt(receiptPreview.mimeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted h-auto gap-2 px-2 py-1.5 font-normal",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
receiptCount === 1
|
||||||
|
? "View receipt"
|
||||||
|
: `View ${receiptCount} receipts`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{previewIsImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={receiptUrl(receiptPreview.id)}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded object-cover ring-1 ring-black/5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded ring-1 ring-black/5">
|
||||||
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<Paperclip className="h-3 w-3" />
|
||||||
|
{receiptCount}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ReceiptViewerDialog
|
||||||
|
receipt={viewerReceipt}
|
||||||
|
open={!!viewerReceipt}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setViewerReceipt(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={listOpen} onOpenChange={setListOpen}>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Receipts ({receiptCount})</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading receipts…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
receipts.map((receipt) => (
|
||||||
|
<ExpenseReceiptItem
|
||||||
|
key={receipt.id}
|
||||||
|
receipt={receipt}
|
||||||
|
expenseId={expenseId}
|
||||||
|
onView={(r) => {
|
||||||
|
setListOpen(false);
|
||||||
|
setViewerReceipt(r);
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ExternalLink, Eye, FileText, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
formatReceiptSize,
|
||||||
|
isImageReceipt,
|
||||||
|
receiptUrl,
|
||||||
|
} from "~/components/expenses/receipt-utils";
|
||||||
|
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
|
||||||
|
export interface ExpenseReceiptRecord {
|
||||||
|
id: string;
|
||||||
|
originalFilename: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpenseReceiptItemProps {
|
||||||
|
receipt: ExpenseReceiptRecord;
|
||||||
|
expenseId: string;
|
||||||
|
onView: (receipt: ReceiptViewerTarget) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseReceiptItem({
|
||||||
|
receipt,
|
||||||
|
expenseId,
|
||||||
|
onView,
|
||||||
|
compact = false,
|
||||||
|
readOnly = false,
|
||||||
|
}: ExpenseReceiptItemProps) {
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Receipt removed");
|
||||||
|
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||||
|
void utils.expenses.getAll.invalidate();
|
||||||
|
setConfirmDelete(false);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = receiptUrl(receipt.id);
|
||||||
|
const isImage = isImageReceipt(receipt.mimeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "flex items-center gap-2 rounded-md border p-2"
|
||||||
|
: "flex items-center gap-3 rounded-md border p-2 sm:p-3"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onView(receipt)}
|
||||||
|
className="hover:ring-primary/40 focus-visible:ring-ring shrink-0 overflow-hidden rounded transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||||
|
aria-label={`View ${receipt.originalFilename}`}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className={
|
||||||
|
compact ? "h-10 w-10 object-cover" : "h-12 w-12 object-cover sm:h-14 sm:w-14"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "bg-muted flex h-10 w-10 items-center justify-center"
|
||||||
|
: "bg-muted flex h-12 w-12 items-center justify-center sm:h-14 sm:w-14"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="text-muted-foreground h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatReceiptSize(receipt.sizeBytes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onView(receipt)}
|
||||||
|
title="View receipt"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" title="Open in new tab">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8 p-0"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={deleteReceipt.isPending}
|
||||||
|
title="Delete receipt"
|
||||||
|
>
|
||||||
|
{deleteReceipt.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete receipt?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
“{receipt.originalFilename}” will be permanently
|
||||||
|
removed. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteReceipt.isPending}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={deleteReceipt.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteReceipt.mutate({ id: receipt.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Loader2, Paperclip } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { FileUpload } from "~/components/forms/file-upload";
|
||||||
|
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||||
|
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||||
|
import {
|
||||||
|
fileToBase64,
|
||||||
|
RECEIPT_ACCEPT,
|
||||||
|
RECEIPT_MAX_SIZE,
|
||||||
|
RECEIPT_UPLOAD_HINT,
|
||||||
|
} from "~/components/expenses/receipt-utils";
|
||||||
|
|
||||||
|
interface ExpenseReceiptsPanelProps {
|
||||||
|
expenseId: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseReceiptsPanel({
|
||||||
|
expenseId,
|
||||||
|
readOnly = false,
|
||||||
|
}: ExpenseReceiptsPanelProps) {
|
||||||
|
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [uploadKey, setUploadKey] = useState(0);
|
||||||
|
const processedFileCountRef = useRef(0);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||||
|
{ expenseId: expenseId! },
|
||||||
|
{ enabled: !!expenseId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
if (expenseId) {
|
||||||
|
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||||
|
void utils.expenses.getAll.invalidate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFiles = async (files: File[]) => {
|
||||||
|
if (!expenseId || files.length === 0) return;
|
||||||
|
|
||||||
|
const newFiles = files.slice(processedFileCountRef.current);
|
||||||
|
processedFileCountRef.current = files.length;
|
||||||
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
for (const file of newFiles) {
|
||||||
|
try {
|
||||||
|
const data = await fileToBase64(file);
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
uploaded++;
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Upload failed";
|
||||||
|
toast.error(`${file.name}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploaded > 0) {
|
||||||
|
toast.success(
|
||||||
|
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
|
||||||
|
);
|
||||||
|
processedFileCountRef.current = 0;
|
||||||
|
setUploadKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!expenseId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
Receipts
|
||||||
|
</Label>
|
||||||
|
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||||
|
Save the expense first, then drag and drop receipts here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
Receipts
|
||||||
|
{receipts.length > 0 && (
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
({receipts.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{uploadReceipt.isPending && (
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Uploading…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading receipts…
|
||||||
|
</div>
|
||||||
|
) : receipts.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||||
|
{readOnly
|
||||||
|
? "No receipts attached."
|
||||||
|
: "No receipts yet. Drop images or PDFs below."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<ExpenseReceiptItem
|
||||||
|
key={receipt.id}
|
||||||
|
receipt={receipt}
|
||||||
|
expenseId={expenseId}
|
||||||
|
onView={setViewerReceipt}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<FileUpload
|
||||||
|
key={`${expenseId}-${uploadKey}`}
|
||||||
|
onFilesSelected={(files) => void handleFiles(files)}
|
||||||
|
accept={RECEIPT_ACCEPT}
|
||||||
|
maxFiles={5}
|
||||||
|
maxSize={RECEIPT_MAX_SIZE}
|
||||||
|
disabled={uploadReceipt.isPending}
|
||||||
|
placeholder="Drop receipts here or tap to browse"
|
||||||
|
description={RECEIPT_UPLOAD_HINT}
|
||||||
|
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReceiptViewerDialog
|
||||||
|
receipt={viewerReceipt}
|
||||||
|
open={!!viewerReceipt}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setViewerReceipt(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const RECEIPT_ACCEPT: Record<string, string[]> = {
|
||||||
|
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
||||||
|
"application/pdf": [".pdf"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const RECEIPT_UPLOAD_HINT =
|
||||||
|
"PNG, JPG, or PDF · up to 10MB each";
|
||||||
|
|
||||||
|
export function receiptUrl(receiptId: string) {
|
||||||
|
return `/api/receipts/${receiptId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageReceipt(mimeType: string) {
|
||||||
|
return mimeType.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfReceipt(mimeType: string) {
|
||||||
|
return mimeType === "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReceiptSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(",")[1];
|
||||||
|
if (!base64) {
|
||||||
|
reject(new Error("Failed to read file"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = () =>
|
||||||
|
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink, FileText } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
isImageReceipt,
|
||||||
|
isPdfReceipt,
|
||||||
|
receiptUrl,
|
||||||
|
} from "~/components/expenses/receipt-utils";
|
||||||
|
|
||||||
|
export interface ReceiptViewerTarget {
|
||||||
|
id: string;
|
||||||
|
originalFilename: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiptViewerDialogProps {
|
||||||
|
receipt: ReceiptViewerTarget | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReceiptViewerDialog({
|
||||||
|
receipt,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ReceiptViewerDialogProps) {
|
||||||
|
if (!receipt) return null;
|
||||||
|
|
||||||
|
const url = receiptUrl(receipt.id);
|
||||||
|
const isImage = isImageReceipt(receipt.mimeType);
|
||||||
|
const isPdf = isPdfReceipt(receipt.mimeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="truncate pr-8">
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
|
||||||
|
{isImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={receipt.originalFilename}
|
||||||
|
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
title={receipt.originalFilename}
|
||||||
|
className="h-[min(70vh,720px)] w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
|
||||||
|
<FileText className="h-10 w-10" />
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open file
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 sm:justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open in new tab
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddressForm } from "~/components/forms/address-form";
|
import { AddressForm } from "~/components/forms/address-form";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -106,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
// Fetch business data if editing
|
// Fetch business data if editing
|
||||||
const { data: business, isLoading: isLoadingBusiness } =
|
const { data: business, isLoading: isLoadingBusiness } =
|
||||||
api.businesses.getById.useQuery(
|
api.businesses.getById.useQuery(
|
||||||
{ id: businessId! },
|
{ id: businessId! },
|
||||||
{ enabled: mode === "edit" && !!businessId },
|
{
|
||||||
|
enabled: mode === "edit" && !!businessId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch email configuration if editing
|
// Fetch email configuration if editing
|
||||||
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
||||||
api.businesses.getEmailConfig.useQuery(
|
api.businesses.getEmailConfig.useQuery(
|
||||||
{ id: businessId! },
|
{ id: businessId! },
|
||||||
{ enabled: mode === "edit" && !!businessId },
|
{
|
||||||
|
enabled: mode === "edit" && !!businessId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update email configuration mutation
|
// Update email configuration mutation
|
||||||
@@ -140,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load business data when editing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (business && mode === "edit") {
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
|
||||||
|
setInitialized(false);
|
||||||
|
setIsDirty(false);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
}, [businessId]);
|
||||||
|
|
||||||
|
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
business &&
|
||||||
|
mode === "edit" &&
|
||||||
|
!initialized &&
|
||||||
|
!isLoadingEmailConfig
|
||||||
|
) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
name: business.name,
|
name: business.name,
|
||||||
@@ -162,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
resendDomain: emailConfig?.resendDomain ?? "",
|
resendDomain: emailConfig?.resendDomain ?? "",
|
||||||
emailFromName: emailConfig?.emailFromName ?? "",
|
emailFromName: emailConfig?.emailFromName ?? "",
|
||||||
});
|
});
|
||||||
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [business, emailConfig, mode]);
|
}, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string | boolean) => {
|
const handleInputChange = (field: string, value: string | boolean) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -408,7 +430,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
(mode === "edit" && isLoadingEmailConfig)
|
(mode === "edit" && isLoadingEmailConfig)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
@@ -430,21 +452,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={mode === "edit" ? "Edit Business" : "Add Business"}
|
title={mode === "edit" ? "Edit Business" : "Add Business"}
|
||||||
description={
|
description={
|
||||||
mode === "edit"
|
mode === "edit"
|
||||||
? "Update business information below"
|
? "Update business information below"
|
||||||
: "Enter business details below to add a new business."
|
: "Enter business details below to add a new business."
|
||||||
}
|
}
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -469,9 +490,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<form id="business-form" onSubmit={handleSubmit} className="space-y-6">
|
<form
|
||||||
|
id="business-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={cn("flex flex-col", dashboardGapClass)}
|
||||||
|
>
|
||||||
{/* Main Form Container - styled like data table */}
|
{/* Main Form Container - styled like data table */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
@@ -902,7 +927,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DashboardPage>
|
||||||
|
|
||||||
<FloatingActionBar
|
<FloatingActionBar
|
||||||
leftContent={
|
leftContent={
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
|
|||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { AddressForm } from "~/components/forms/address-form";
|
import { AddressForm } from "~/components/forms/address-form";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import {
|
import {
|
||||||
@@ -88,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
// Fetch client data if editing
|
// Fetch client data if editing
|
||||||
const { data: client, isLoading: isLoadingClient } =
|
const { data: client, isLoading: isLoadingClient } =
|
||||||
api.clients.getById.useQuery(
|
api.clients.getById.useQuery(
|
||||||
{ id: clientId! },
|
{ id: clientId! },
|
||||||
{ enabled: mode === "edit" && !!clientId },
|
{
|
||||||
|
enabled: mode === "edit" && !!clientId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const createClient = api.clients.create.useMutation({
|
const createClient = api.clients.create.useMutation({
|
||||||
@@ -116,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load client data when editing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && mode === "edit") {
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
|
||||||
|
setInitialized(false);
|
||||||
|
setIsDirty(false);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (client && mode === "edit" && !initialized) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
name: client.name,
|
name: client.name,
|
||||||
@@ -133,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||||
currency: client.currency ?? "USD",
|
currency: client.currency ?? "USD",
|
||||||
});
|
});
|
||||||
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [client, mode]);
|
}, [client, mode, initialized]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string | number | null) => {
|
const handleInputChange = (field: string, value: string | number | null) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -237,7 +251,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
|
|
||||||
if (mode === "edit" && isLoadingClient) {
|
if (mode === "edit" && isLoadingClient) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
@@ -259,21 +273,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
||||||
description={
|
description={
|
||||||
mode === "edit"
|
mode === "edit"
|
||||||
? "Update client information below"
|
? "Update client information below"
|
||||||
: "Enter client details below to add a new client."
|
: "Enter client details below to add a new client."
|
||||||
}
|
}
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -298,9 +311,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
|
<form
|
||||||
|
id="client-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={cn("flex flex-col", dashboardGapClass)}
|
||||||
|
>
|
||||||
{/* Main Form Container - styled like data table */}
|
{/* Main Form Container - styled like data table */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
@@ -508,7 +525,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DashboardPage>
|
||||||
|
|
||||||
<FloatingActionBar
|
<FloatingActionBar
|
||||||
leftContent={
|
leftContent={
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
|
|
||||||
interface EmailPreviewProps {
|
interface EmailPreviewProps {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -53,7 +55,7 @@ export function EmailPreview({
|
|||||||
const calculateTotal = () => {
|
const calculateTotal = () => {
|
||||||
if (!invoice?.items) return 0;
|
if (!invoice?.items) return 0;
|
||||||
const subtotal = invoice.items.reduce(
|
const subtotal = invoice.items.reduce(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
@@ -82,17 +84,14 @@ export function EmailPreview({
|
|||||||
description: item.description ?? "Service",
|
description: item.description ?? "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount ?? item.hours * item.rate,
|
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
},
|
},
|
||||||
customContent: content,
|
customContent: content,
|
||||||
customMessage: customMessage,
|
customMessage: customMessage,
|
||||||
userName: invoice.business?.name ?? "Your Business",
|
userName: invoice.business?.name ?? "Your Business",
|
||||||
userEmail: fromEmail,
|
userEmail: fromEmail,
|
||||||
baseUrl:
|
baseUrl: getAppUrl(),
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.location.origin
|
|
||||||
: "https://beenvoice.app",
|
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
pageTabsGridClass,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -17,7 +26,6 @@ import {
|
|||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { InvoiceLineItems } from "./invoice-line-items";
|
import { InvoiceLineItems } from "./invoice-line-items";
|
||||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||||
import { EmailPreview } from "./email-preview";
|
import { EmailPreview } from "./email-preview";
|
||||||
@@ -52,6 +60,11 @@ import {
|
|||||||
import { STATUS_OPTIONS } from "./invoice/types";
|
import { STATUS_OPTIONS } from "./invoice/types";
|
||||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
|
import {
|
||||||
|
applyBillingTypeChange,
|
||||||
|
calculateLineItemAmount,
|
||||||
|
getLineItemBillingType,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
||||||
|
|
||||||
import { CountUp } from "~/components/ui/count-up";
|
import { CountUp } from "~/components/ui/count-up";
|
||||||
@@ -62,19 +75,17 @@ interface InvoiceFormProps {
|
|||||||
|
|
||||||
function InvoiceFormSkeleton() {
|
function InvoiceFormSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-8">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="Loading invoice form"
|
description="Loading invoice form"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
|
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
|
||||||
{/* Tabs Skeleton */}
|
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +106,7 @@ function plainTextToHtml(value: string) {
|
|||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
return {
|
return {
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
invoicePrefix: "#",
|
invoicePrefix: "#",
|
||||||
@@ -109,30 +120,27 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
|||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: blank
|
items: [
|
||||||
? []
|
{
|
||||||
: [
|
id: crypto.randomUUID(),
|
||||||
{
|
date: new Date(),
|
||||||
id: crypto.randomUUID(),
|
description: "",
|
||||||
date: new Date(),
|
hours: 1,
|
||||||
description: "",
|
rate: 0,
|
||||||
hours: 1,
|
amount: 0,
|
||||||
rate: 0,
|
billingType: "hourly",
|
||||||
amount: 0,
|
},
|
||||||
},
|
],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const isBlank = searchParams.get("blank") === "1";
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||||
createDefaultInvoiceFormData(isBlank),
|
createDefaultInvoiceFormData(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -140,15 +148,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
const [previewTab, setPreviewTab] = useState("pdf");
|
const [previewTab, setPreviewTab] = useState("pdf");
|
||||||
const [previewPinned, setPreviewPinned] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia("(min-width: 1024px)");
|
|
||||||
const update = () => setPreviewPinned(media.matches);
|
|
||||||
update();
|
|
||||||
media.addEventListener("change", update);
|
|
||||||
return () => media.removeEventListener("change", update);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
@@ -179,7 +178,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}, [invoiceId]);
|
}, [invoiceId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
|
||||||
const mappedItems: InvoiceItem[] =
|
const mappedItems: InvoiceItem[] =
|
||||||
existingInvoice.items?.map((item) => ({
|
existingInvoice.items?.map((item) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -188,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
|
billingType: getLineItemBillingType(item.hours),
|
||||||
})) || [];
|
})) || [];
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -214,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
hours: 1,
|
hours: 1,
|
||||||
rate: 0,
|
rate: 0,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -231,9 +231,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}
|
}
|
||||||
}, [invoiceId, existingInvoice, businesses, initialized]);
|
}, [invoiceId, existingInvoice, businesses, initialized]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
invoiceId &&
|
||||||
|
invoiceId !== "new" &&
|
||||||
|
existingInvoice &&
|
||||||
|
!loadingInvoice &&
|
||||||
|
existingInvoice.status !== "draft"
|
||||||
|
) {
|
||||||
|
toast.error("Only draft invoices can be edited");
|
||||||
|
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
}
|
||||||
|
}, [invoiceId, existingInvoice, loadingInvoice, router]);
|
||||||
|
|
||||||
const totals = React.useMemo(() => {
|
const totals = React.useMemo(() => {
|
||||||
const subtotal = formData.items.reduce(
|
const subtotal = formData.items.reduce(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||||
@@ -261,9 +274,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
items: formData.items.map((item) => ({
|
items: formData.items.map((item) => ({
|
||||||
date: item.date,
|
date: item.date,
|
||||||
description: item.description || "Service",
|
description: item.description || "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
})),
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
[formData],
|
[formData],
|
||||||
);
|
);
|
||||||
@@ -293,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
hours: 1,
|
hours: 1,
|
||||||
rate: prev.defaultHourlyRate ?? 0,
|
rate: prev.defaultHourlyRate ?? 0,
|
||||||
amount: prev.defaultHourlyRate ?? 0,
|
amount: prev.defaultHourlyRate ?? 0,
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
@@ -308,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: parsed.description,
|
description: parsed.description,
|
||||||
hours: parsed.hours ?? 1,
|
hours: parsed.hours ?? 1,
|
||||||
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||||
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0),
|
amount: calculateLineItemAmount(
|
||||||
|
parsed.hours ?? 1,
|
||||||
|
parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||||
|
),
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
@@ -328,14 +347,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((item, i) => {
|
items: prev.items.map((item, i) => {
|
||||||
if (i === idx) {
|
if (i !== idx) return item;
|
||||||
const updated = { ...item, [field]: value };
|
|
||||||
if (field === "hours" || field === "rate") {
|
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
||||||
updated.amount = updated.hours * updated.rate;
|
const next = applyBillingTypeChange(value, item);
|
||||||
}
|
return {
|
||||||
return updated;
|
...item,
|
||||||
|
...next,
|
||||||
|
billingType: value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return item;
|
|
||||||
|
const updated = { ...item, [field]: value };
|
||||||
|
if (field === "hours" || field === "rate") {
|
||||||
|
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
|
||||||
|
updated.billingType = getLineItemBillingType(updated.hours);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -430,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: i.description,
|
description: i.description,
|
||||||
hours: i.hours,
|
hours: i.hours,
|
||||||
rate: i.rate,
|
rate: i.rate,
|
||||||
amount: i.hours * i.rate,
|
amount: calculateLineItemAmount(i.hours, i.rate),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
||||||
@@ -456,27 +484,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
!initialized ||
|
!initialized ||
|
||||||
loadingClients ||
|
loadingClients ||
|
||||||
loadingBusinesses ||
|
loadingBusinesses ||
|
||||||
(invoiceId && invoiceId !== "new" && loadingInvoice)
|
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
|
||||||
|
(invoiceId &&
|
||||||
|
invoiceId !== "new" &&
|
||||||
|
existingInvoice &&
|
||||||
|
existingInvoice.status !== "draft")
|
||||||
)
|
)
|
||||||
return <InvoiceFormSkeleton />;
|
return <InvoiceFormSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-enter space-y-6 pb-8">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={
|
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||||
invoiceId !== "new"
|
description="Manage your invoice"
|
||||||
? "Edit Invoice"
|
|
||||||
: isBlank
|
|
||||||
? "Blank Invoice"
|
|
||||||
: "Create Invoice"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
isBlank
|
|
||||||
? "Set up a draft to clock time into later"
|
|
||||||
: "Manage your invoice"
|
|
||||||
}
|
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
{invoiceId !== "new" && (
|
{invoiceId !== "new" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -491,43 +512,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{loading ? "Saving..." : "Save"}
|
{loading ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
<PageTabsList>
|
||||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
|
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||||
<TabsTrigger
|
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||||
value="details"
|
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
</PageTabsList>
|
||||||
>
|
|
||||||
Details
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="items"
|
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
Items
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="timesheet"
|
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
Timesheet
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="preview"
|
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* DETAILS TAB */}
|
{/* DETAILS TAB */}
|
||||||
<TabsContent
|
<PageTabsContent value="details">
|
||||||
value="details"
|
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||||
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
|
||||||
>
|
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2 text-base">
|
<CardTitle className="flex gap-2 text-base">
|
||||||
@@ -770,14 +767,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* ITEMS TAB */}
|
{/* ITEMS TAB */}
|
||||||
<TabsContent
|
<PageTabsContent value="items">
|
||||||
value="items"
|
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
|
||||||
className="mt-6 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<Card className="bg-primary/5 border-primary/20">
|
<Card className="bg-primary/5 border-primary/20">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<span className="text-muted-foreground">Total</span>
|
<span className="text-muted-foreground">Total</span>
|
||||||
@@ -799,7 +794,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<span className="text-muted-foreground">Hours</span>
|
<span className="text-muted-foreground">Hours</span>
|
||||||
<span className="font-mono text-xl font-semibold">
|
<span className="font-mono text-xl font-semibold">
|
||||||
<CountUp
|
<CountUp
|
||||||
value={formData.items.reduce((s, i) => s + i.hours, 0)}
|
value={formData.items.reduce(
|
||||||
|
(s, i) => s + (i.hours > 0 ? i.hours : 0),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
suffix="h"
|
suffix="h"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -826,13 +824,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* TIMESHEET TAB */}
|
{/* TIMESHEET TAB */}
|
||||||
<TabsContent
|
<PageTabsContent value="timesheet">
|
||||||
value="timesheet"
|
|
||||||
className="mt-6 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<Card className="min-h-[600px] w-full">
|
<Card className="min-h-[600px] w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2">
|
<CardTitle className="flex gap-2">
|
||||||
@@ -850,44 +845,51 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<PageTabsContent value="preview">
|
||||||
value="preview"
|
<Card className="overflow-hidden">
|
||||||
className="mt-6 focus-visible:outline-none"
|
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
|
||||||
>
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Tabs
|
<FileText className="h-4 w-4" />
|
||||||
value={previewTab}
|
Preview
|
||||||
onValueChange={setPreviewTab}
|
</CardTitle>
|
||||||
className="w-full"
|
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||||
>
|
<button
|
||||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
|
type="button"
|
||||||
<TabsTrigger
|
onClick={() => setPreviewTab("pdf")}
|
||||||
value="pdf"
|
className={cn(
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
>
|
previewTab === "pdf"
|
||||||
PDF
|
? "bg-background text-foreground shadow"
|
||||||
</TabsTrigger>
|
: "text-muted-foreground hover:text-foreground",
|
||||||
<TabsTrigger
|
)}
|
||||||
value="email"
|
>
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
PDF
|
||||||
>
|
</button>
|
||||||
Email
|
<button
|
||||||
</TabsTrigger>
|
type="button"
|
||||||
</TabsList>
|
onClick={() => setPreviewTab("email")}
|
||||||
|
className={cn(
|
||||||
<TabsContent value="pdf" className="mt-6">
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
previewTab === "email"
|
||||||
</TabsContent>
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
<TabsContent value="email" className="mt-6">
|
)}
|
||||||
<Card>
|
>
|
||||||
<CardHeader>
|
Email
|
||||||
<CardTitle className="flex gap-2">
|
</button>
|
||||||
<Mail className="h-5 w-5" /> Email Preview
|
</div>
|
||||||
</CardTitle>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent className="p-0">
|
||||||
<CardContent>
|
{previewTab === "pdf" ? (
|
||||||
|
<InvoicePdfPreviewPanel
|
||||||
|
embedded
|
||||||
|
input={pdfPreviewInput}
|
||||||
|
enabled={activeTab === "preview" && previewTab === "pdf"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border-t p-6">
|
||||||
<EmailPreview
|
<EmailPreview
|
||||||
subject={`Invoice ${formData.invoiceNumber} from ${
|
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||||
selectedBusiness?.name ?? "Your Business"
|
selectedBusiness?.name ?? "Your Business"
|
||||||
@@ -922,35 +924,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.hours * item.rate,
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
</TabsContent>
|
</CardContent>
|
||||||
</Tabs>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
|
</DashboardPage>
|
||||||
<aside className="hidden lg:block">
|
|
||||||
<div className="sticky top-4 space-y-4">
|
|
||||||
<InvoicePdfPreviewPanel
|
|
||||||
input={pdfPreviewInput}
|
|
||||||
enabled={previewPinned || activeTab === "preview"}
|
|
||||||
/>
|
|
||||||
<Card className="border-primary/20 bg-primary/5">
|
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
|
||||||
<span className="text-muted-foreground text-sm">Invoice total</span>
|
|
||||||
<span className="font-mono text-2xl font-bold">
|
|
||||||
<CountUp value={totals.total} prefix="$" />
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
|
|||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
calculateLineItemAmount,
|
||||||
|
getLineItemBillingType,
|
||||||
|
type LineItemBillingType,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
import {
|
import {
|
||||||
useLineItemSuggestions,
|
useLineItemSuggestions,
|
||||||
type LineItemSuggestion,
|
type LineItemSuggestion,
|
||||||
} from "~/hooks/use-line-item-suggestions";
|
} from "~/hooks/use-line-item-suggestions";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
|
||||||
interface InvoiceItem {
|
interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +35,7 @@ interface InvoiceItem {
|
|||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
billingType?: LineItemBillingType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceLineItemsProps {
|
interface InvoiceLineItemsProps {
|
||||||
@@ -149,13 +162,21 @@ function DescriptionAutocomplete({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LINE_ITEM_GRID =
|
||||||
|
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
|
||||||
|
|
||||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
||||||
|
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||||
|
const isFixed = billingType === "fixed";
|
||||||
|
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
"group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||||
|
LINE_ITEM_GRID,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -177,16 +198,36 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<Select
|
||||||
value={item.hours}
|
value={billingType}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onValueChange={(value: LineItemBillingType) =>
|
||||||
min={0}
|
onUpdate(index, "billingType", value)
|
||||||
step={0.25}
|
}
|
||||||
width="full"
|
|
||||||
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
|
||||||
suffix="h"
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isFixed ? (
|
||||||
|
<span className="text-muted-foreground text-center text-xs">—</span>
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
width="full"
|
||||||
|
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||||
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${lineTotal.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -235,6 +276,10 @@ function MobileLineItem({
|
|||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
|
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||||
|
const isFixed = billingType === "fixed";
|
||||||
|
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`invoice-item-${index}-mobile`}
|
id={`invoice-item-${index}-mobile`}
|
||||||
@@ -260,20 +305,37 @@ function MobileLineItem({
|
|||||||
date={item.date}
|
date={item.date}
|
||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-[92px] shrink-0"
|
className="w-auto shrink-0"
|
||||||
inputClassName="h-8 px-2 text-xs"
|
inputClassName="h-8 px-2 text-xs"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<Select
|
||||||
value={item.hours}
|
value={billingType}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onValueChange={(value: LineItemBillingType) =>
|
||||||
min={0}
|
onUpdate(index, "billingType", value)
|
||||||
step={0.25}
|
}
|
||||||
width="full"
|
|
||||||
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
|
|
||||||
suffix="h"
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!isFixed ? (
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
width="full"
|
||||||
|
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
|
||||||
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
onChange={(value) => onUpdate(index, "rate", value)}
|
onChange={(value) => onUpdate(index, "rate", value)}
|
||||||
@@ -285,7 +347,7 @@ function MobileLineItem({
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${lineTotal.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
|
|||||||
onUpdateItem(index, "description", s.description);
|
onUpdateItem(index, "description", s.description);
|
||||||
onUpdateItem(index, "hours", s.hours);
|
onUpdateItem(index, "hours", s.hours);
|
||||||
onUpdateItem(index, "rate", s.rate);
|
onUpdateItem(index, "rate", s.rate);
|
||||||
|
onUpdateItem(index, "billingType", "hourly");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setQueriedIndex(null);
|
setQueriedIndex(null);
|
||||||
}
|
}
|
||||||
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
|
|||||||
) : null}
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
<div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
|
||||||
<span>Date</span>
|
<span>Date</span>
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
|
<span className="text-center">Type</span>
|
||||||
<span className="text-center">Hours</span>
|
<span className="text-center">Hours</span>
|
||||||
<span className="text-center">Rate</span>
|
<span className="text-center">Rate</span>
|
||||||
<span className="text-right">Amount</span>
|
<span className="text-right">Amount</span>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
heightClassName?: string;
|
heightClassName?: string;
|
||||||
|
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
|
||||||
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoicePdfPreviewPanel({
|
export function InvoicePdfPreviewPanel({
|
||||||
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
className,
|
className,
|
||||||
heightClassName = "h-[min(80vh,760px)]",
|
heightClassName = "h-[min(80vh,760px)]",
|
||||||
|
embedded = false,
|
||||||
}: InvoicePdfPreviewPanelProps) {
|
}: InvoicePdfPreviewPanelProps) {
|
||||||
const previewReady = canPreview(input);
|
const previewReady = canPreview(input);
|
||||||
|
|
||||||
@@ -54,6 +57,48 @@ export function InvoicePdfPreviewPanel({
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const previewBody = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/20 overflow-hidden border-t",
|
||||||
|
heightClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!previewReady ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Select a client and add descriptions for all line items to generate the
|
||||||
|
PDF preview.
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<p className="text-destructive text-sm">{error.message}</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : isFetching && !pdfPreview ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Generating preview…
|
||||||
|
</div>
|
||||||
|
) : pdfPreview ? (
|
||||||
|
<iframe
|
||||||
|
title="Invoice PDF preview"
|
||||||
|
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
PDF preview will appear here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("overflow-hidden", className)}>
|
<Card className={cn("overflow-hidden", className)}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -63,43 +108,7 @@ export function InvoicePdfPreviewPanel({
|
|||||||
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">{previewBody}</CardContent>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-muted/20 overflow-hidden border-t",
|
|
||||||
heightClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!previewReady ? (
|
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
|
||||||
Select a client and add descriptions for all line items to generate the
|
|
||||||
PDF preview.
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
|
||||||
<p className="text-destructive text-sm">{error.message}</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : isFetching && !pdfPreview ? (
|
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Generating preview…
|
|
||||||
</div>
|
|
||||||
) : pdfPreview ? (
|
|
||||||
<iframe
|
|
||||||
title="Invoice PDF preview"
|
|
||||||
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
|
||||||
className="h-full w-full border-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
|
||||||
PDF preview will appear here.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
|
||||||
|
export const dashboardGapClass = "gap-5 md:gap-6";
|
||||||
|
|
||||||
|
/** Standard grid gap for dashboard cards and sections. */
|
||||||
|
export const dashboardGridClass =
|
||||||
|
"grid gap-5 md:gap-6";
|
||||||
|
|
||||||
|
/** Summary stat cards (2-up mobile, 4-up desktop). */
|
||||||
|
export const dashboardStatGridClass =
|
||||||
|
"grid grid-cols-2 gap-4 sm:grid-cols-4";
|
||||||
|
|
||||||
|
export function DashboardPage({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"page-enter mx-auto flex w-full max-w-7xl flex-col",
|
||||||
|
dashboardGapClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardGrid({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn(dashboardGridClass, className)}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardCardTitle({
|
||||||
|
children,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { Sidebar } from "~/components/layout/sidebar";
|
import { Sidebar } from "~/components/layout/sidebar";
|
||||||
import {
|
import {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
|
|||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
|
||||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||||
|
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const { sidebarStyle } = useAppearance();
|
const pathname = usePathname();
|
||||||
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
||||||
|
const isOnboarding = pathname === "/dashboard/onboarding";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-dashboard relative flex min-h-screen">
|
<div className="bg-dashboard relative flex min-h-screen">
|
||||||
{/* Desktop Sidebar */}
|
{!isOnboarding && (
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile Sidebar (Sheet) */}
|
<div
|
||||||
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
|
className={cn(
|
||||||
|
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
|
||||||
|
isOnboarding && "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
<span className="sr-only">Toggle menu</span>
|
<span className="sr-only">Toggle menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
{/* Mobile Link / Logo */}
|
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||||
<div className="ml-4 flex items-center gap-2">
|
<Logo size="sm" className="shrink-0" />
|
||||||
<Logo size="sm" />
|
<ActiveTimerWidget compact />
|
||||||
</div>
|
</div>
|
||||||
<SheetContent side="left" className="w-72 p-0">
|
<SheetContent side="left" className="w-72 p-0">
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
@@ -53,30 +60,20 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main
|
<main
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
|
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
|
||||||
"md:ml-0",
|
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||||
sidebarStyle === "floating"
|
|
||||||
? isCollapsed
|
|
||||||
? "md:ml-24"
|
|
||||||
: "md:ml-[18rem]"
|
|
||||||
: isCollapsed
|
|
||||||
? "md:ml-16"
|
|
||||||
: "md:ml-64",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
{isOnboarding ? (
|
||||||
<div className="mb-4 md:hidden">
|
<OnboardingGuard>{children}</OnboardingGuard>
|
||||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
) : (
|
||||||
|
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||||
|
<OnboardingGuard>{children}</OnboardingGuard>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
)}
|
||||||
<ActiveTimerWidget />
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface DashboardUserContextValue {
|
||||||
|
isAdmin: boolean;
|
||||||
|
needsOnboarding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardUserContext = createContext<DashboardUserContextValue>({
|
||||||
|
isAdmin: false,
|
||||||
|
needsOnboarding: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function DashboardUserProvider({
|
||||||
|
isAdmin,
|
||||||
|
needsOnboarding,
|
||||||
|
children,
|
||||||
|
}: DashboardUserContextValue & { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
|
||||||
|
{children}
|
||||||
|
</DashboardUserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardUser() {
|
||||||
|
return useContext(DashboardUserContext);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user