Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c267b3e1c8 | ||
|
|
5e6e51337a |
+13
-2
@@ -130,8 +130,18 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Receipt storage — S3-compatible (optional)
|
# Receipt storage — S3-compatible (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# When unset, receipt files are stored locally in .data/receipts/ (dev-friendly).
|
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
|
||||||
# Works with AWS S3, MinIO, Cloudflare R2, etc.
|
# .data/receipts/ (dev-friendly). Works with AWS S3, MinIO, Cloudflare R2, etc.
|
||||||
|
#
|
||||||
|
# S3_ENDPOINT — who can reach MinIO?
|
||||||
|
# • Host dev (bun dev + docker-compose.dev.yml MinIO on the host): localhost:9000
|
||||||
|
# • App in Docker (docker-compose.yml): http://minio:9000 (Compose service name)
|
||||||
|
# • Coolify — see docs/COOLIFY.md. Summary:
|
||||||
|
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+minio).
|
||||||
|
# - Application + separate MinIO: ENOTFOUND minio → set S3_ENDPOINT to
|
||||||
|
# SERVICE_URL_MINIO_9000 (public domain) OR http://minio-<resource-uuid>:9000
|
||||||
|
# with Connect to Predefined Network on both resources. Never bare "minio".
|
||||||
|
# - NEVER use localhost in production — inside the app container that is the app, not MinIO.
|
||||||
#
|
#
|
||||||
# Local dev with docker-compose.dev.yml MinIO (host `bun dev`):
|
# Local dev with docker-compose.dev.yml MinIO (host `bun dev`):
|
||||||
S3_ENDPOINT=http://localhost:9000
|
S3_ENDPOINT=http://localhost:9000
|
||||||
@@ -139,6 +149,7 @@ S3_BUCKET=beenvoice-receipts
|
|||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=minioadmin
|
||||||
S3_SECRET_KEY=minioadmin
|
S3_SECRET_KEY=minioadmin
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
|
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for MinIO/HTTPS proxy
|
||||||
#
|
#
|
||||||
# docker-compose.yml sets S3_ENDPOINT=http://minio:9000 inside the app container
|
# docker-compose.yml sets S3_ENDPOINT=http://minio:9000 inside the app container
|
||||||
# automatically. MINIO_ROOT_* below must match S3_ACCESS_KEY / S3_SECRET_KEY.
|
# automatically. MINIO_ROOT_* below must match S3_ACCESS_KEY / S3_SECRET_KEY.
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ Prune old app images occasionally: `docker image prune -f` (or remove specific `
|
|||||||
|
|
||||||
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 minio` with Application + separate MinIO 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:
|
||||||
@@ -190,7 +194,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 + MinIO (deploy)
|
||||||
|
├── docker-compose.coolify.yml # Coolify Compose (app + db + minio)
|
||||||
|
├── docker-compose.coolify-minio.yml # MinIO-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
|
||||||
```
|
```
|
||||||
@@ -250,6 +256,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 MinIO 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 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# MinIO-only stack for Coolify when Beevoice runs as a separate Application resource.
|
||||||
|
#
|
||||||
|
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml
|
||||||
|
#
|
||||||
|
# ── Fix ENOTFOUND minio on a Beevoice Application (pick ONE) ─────────────────
|
||||||
|
#
|
||||||
|
# A) Public MinIO URL (most reliable — no shared Docker network required)
|
||||||
|
# 1. Redeploy this stack (includes SERVICE_FQDN_MINIO_9000 below).
|
||||||
|
# 2. MinIO resource → assign a domain for port 9000 (e.g. s3.example.com).
|
||||||
|
# 3. Open this resource's Environment tab → copy SERVICE_URL_MINIO_9000
|
||||||
|
# (e.g. https://s3.example.com).
|
||||||
|
# 4. Beevoice Application → S3_ENDPOINT=<that URL> → redeploy Beevoice.
|
||||||
|
#
|
||||||
|
# B) Internal Docker DNS (same Coolify destination network)
|
||||||
|
# 1. MinIO resource → Advanced → enable "Connect to Predefined Network" → redeploy.
|
||||||
|
# 2. Beevoice Application → same destination → enable "Connect to Predefined Network"
|
||||||
|
# → redeploy.
|
||||||
|
# 3. Beevoice → S3_ENDPOINT=http://minio-<MINIO_RESOURCE_UUID>:9000
|
||||||
|
# (UUID is in the MinIO resource URL / COOLIFY_RESOURCE_UUID — NOT bare "minio").
|
||||||
|
#
|
||||||
|
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+minio).
|
||||||
|
# See docs/COOLIFY.md.
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
SERVICE_FQDN_MINIO_9000:
|
||||||
|
SERVICE_FQDN_MINIO_9001:
|
||||||
|
volumes:
|
||||||
|
- beenvoice_minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
expose:
|
||||||
|
- "9000"
|
||||||
|
- "9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set local http://minio:9000
|
||||||
|
"$${MINIO_ROOT_USER:-minioadmin}"
|
||||||
|
"$${MINIO_ROOT_PASSWORD:-minioadmin}" &&
|
||||||
|
mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
beenvoice_minio_data:
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Beevoice 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, MINIO_ROOT_* in the resource env (see .env.example).
|
||||||
|
# 3. Do NOT override S3_ENDPOINT — this stack sets http://minio:9000 on the shared network.
|
||||||
|
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
|
||||||
|
#
|
||||||
|
# Migrating from Application + separate Postgres + MinIO 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}
|
||||||
|
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
||||||
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
|
S3_ENDPOINT: http://minio:9000
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
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
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
# Optional: assign domains in Coolify for console / external S3 API access.
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
SERVICE_FQDN_MINIO_9001:
|
||||||
|
volumes:
|
||||||
|
- beenvoice_minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
expose:
|
||||||
|
- "9000"
|
||||||
|
- "9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set local http://minio:9000
|
||||||
|
"$${MINIO_ROOT_USER:-minioadmin}"
|
||||||
|
"$${MINIO_ROOT_PASSWORD:-minioadmin}" &&
|
||||||
|
mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
beenvoice_pg_data:
|
||||||
|
beenvoice_minio_data:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
|
|
||||||
# S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001.
|
# S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001.
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
image: minio/minio:latest
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
|
image: minio/mc:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -45,11 +45,13 @@ services:
|
|||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
entrypoint: >
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
/bin/sh -c "
|
command:
|
||||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
- >-
|
||||||
mc mb local/$$S3_BUCKET --ignore-existing
|
mc alias set local http://minio:9000
|
||||||
"
|
"$${MINIO_ROOT_USER:-minioadmin}"
|
||||||
|
"$${MINIO_ROOT_PASSWORD:-minioadmin}" &&
|
||||||
|
mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+13
-7
@@ -1,5 +1,9 @@
|
|||||||
# Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml
|
# Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml
|
||||||
#
|
#
|
||||||
|
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
|
||||||
|
# or this file. S3_ENDPOINT=http://minio:9000 works only inside a single stack.
|
||||||
|
# Application + separate MinIO → docs/COOLIFY.md.
|
||||||
|
#
|
||||||
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
|
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
|
||||||
# the existing local image and will NOT include new code. Use:
|
# the existing local image and will NOT include new code. Use:
|
||||||
# ./scripts/docker-deploy.sh
|
# ./scripts/docker-deploy.sh
|
||||||
@@ -65,7 +69,7 @@ services:
|
|||||||
|
|
||||||
# S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose).
|
# S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose).
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
image: minio/minio:latest
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
@@ -83,7 +87,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
|
image: minio/mc:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -91,11 +95,13 @@ services:
|
|||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
entrypoint: >
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
/bin/sh -c "
|
command:
|
||||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
- >-
|
||||||
mc mb local/$$S3_BUCKET --ignore-existing
|
mc alias set local http://minio:9000
|
||||||
"
|
"$${MINIO_ROOT_USER:-minioadmin}"
|
||||||
|
"$${MINIO_ROOT_PASSWORD:-minioadmin}" &&
|
||||||
|
mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# Coolify deployment — Beevoice + MinIO
|
||||||
|
|
||||||
|
Beevoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. MinIO is the usual choice on self-hosted Coolify.
|
||||||
|
|
||||||
|
## Why `getaddrinfo ENOTFOUND minio` happens
|
||||||
|
|
||||||
|
Docker DNS resolves service names **only inside the same Docker network**.
|
||||||
|
|
||||||
|
| Setup | Does `http://minio:9000` work? |
|
||||||
|
|-------|-------------------------------|
|
||||||
|
| Single Compose stack (app + minio together) | Yes — Compose service name `minio` |
|
||||||
|
| Beevoice **Application** + MinIO **separate Compose** | **No** — each resource has its own network by default |
|
||||||
|
| Application + MinIO with shared destination network + correct hostname | Yes — hostname is usually **`minio-<resource-uuid>`**, not bare `minio` |
|
||||||
|
| Application + MinIO via **public domain** (`SERVICE_URL_MINIO_9000`) | Yes — no Docker DNS needed |
|
||||||
|
|
||||||
|
Setting `S3_ENDPOINT=http://minio:9000` on a standalone Beevoice Application fails because the app container is not on the MinIO stack's network. Node returns `ENOTFOUND minio`.
|
||||||
|
|
||||||
|
Also avoid `http://localhost:9000` inside the app container — that points at the app itself, not MinIO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick fix — keep Beevoice as Application + separate MinIO compose
|
||||||
|
|
||||||
|
Use this if you are **not** migrating to a single Compose stack today.
|
||||||
|
|
||||||
|
### Path A — public MinIO URL (recommended, works without shared Docker network)
|
||||||
|
|
||||||
|
This is the most reliable fix when Beevoice is a Coolify **Application** (Dockerfile) and MinIO is a separate Compose resource.
|
||||||
|
|
||||||
|
1. **Update the MinIO stack** to the latest `docker-compose.coolify-minio.yml` from this repo (includes `SERVICE_FQDN_MINIO_9000`) and **redeploy** the MinIO resource.
|
||||||
|
2. In the **MinIO Compose resource** → assign a domain for **port 9000** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
|
||||||
|
3. Open the MinIO resource **Environment** tab and copy **`SERVICE_URL_MINIO_9000`** (e.g. `https://s3.yourdomain.com`).
|
||||||
|
4. On the **Beevoice Application** → Environment:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=https://s3.yourdomain.com
|
||||||
|
S3_BUCKET=beenvoice-receipts
|
||||||
|
S3_ACCESS_KEY=<same as MINIO_ROOT_USER>
|
||||||
|
S3_SECRET_KEY=<same as MINIO_ROOT_PASSWORD>
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Redeploy Beevoice** (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 MinIO 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 MinIO domain)
|
||||||
|
|
||||||
|
Use when you want MinIO API traffic to stay on the Docker network.
|
||||||
|
|
||||||
|
1. Put Beevoice Application and MinIO Compose in the **same Coolify project** and **same destination** (server/network).
|
||||||
|
2. **MinIO Compose resource** → **Advanced** → enable **Connect to Predefined Network** → **redeploy MinIO**.
|
||||||
|
3. **Beevoice Application** → **Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy Beevoice**.
|
||||||
|
4. Find the MinIO resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the MinIO container).
|
||||||
|
5. Set on Beevoice Application:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=http://minio-<MINIO_RESOURCE_UUID>:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: resource UUID `k8w2o0g4s0g8` → `S3_ENDPOINT=http://minio-k8w2o0g4s0g8:9000`.
|
||||||
|
|
||||||
|
**Do not use bare `minio`** unless you verified it resolves from inside the Beevoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://minio:9000/minio/health/live` fails, use the `minio-<uuid>` form or Path A).
|
||||||
|
|
||||||
|
6. Match credentials and bucket:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_BUCKET=beenvoice-receipts
|
||||||
|
S3_ACCESS_KEY=<MINIO_ROOT_USER>
|
||||||
|
S3_SECRET_KEY=<MINIO_ROOT_PASSWORD>
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended long-term — one Compose stack
|
||||||
|
|
||||||
|
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + MinIO + minio-init). 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`, `MINIO_ROOT_*`, 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://minio:9000` 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/MinIO.
|
||||||
|
|
||||||
|
### Migrating from Application + external Postgres + MinIO
|
||||||
|
|
||||||
|
| Current | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| Beevoice 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 |
|
||||||
|
| MinIO compose | Remove after data migrated or re-point receipts (new bucket) |
|
||||||
|
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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-minio.yml`](../docker-compose.coolify-minio.yml) | MinIO + bucket init only; pair with Beevoice 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 MinIO)
|
||||||
|
|
||||||
|
- [ ] MinIO stack redeployed with current `docker-compose.coolify-minio.yml`
|
||||||
|
- [ ] **Path A:** domain on port 9000 + `S3_ENDPOINT` = `SERVICE_URL_MINIO_9000`
|
||||||
|
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://minio-<uuid>:9000`
|
||||||
|
- [ ] `S3_ENDPOINT` is **not** `http://minio:9000`, **not** `localhost`
|
||||||
|
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`
|
||||||
|
- [ ] `S3_BUCKET` exists (`minio-init` creates `beenvoice-receipts` by default)
|
||||||
|
- [ ] Redeployed Beevoice after env or network changes
|
||||||
|
|
||||||
|
## Verify from the Beevoice container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shell into Beevoice app container on the Coolify server
|
||||||
|
docker exec -it <beenvoice-container> sh
|
||||||
|
|
||||||
|
# Path A — public URL (include scheme; path is /minio/health/live on API port)
|
||||||
|
wget -qO- "https://s3.yourdomain.com/minio/health/live" || curl -sf "https://s3.yourdomain.com/minio/health/live"
|
||||||
|
|
||||||
|
# Path B — internal host from S3_ENDPOINT (no scheme/port in HOST)
|
||||||
|
wget -qO- "http://minio-<uuid>:9000/minio/health/live" || curl -sf "http://minio-<uuid>:9000/minio/health/live"
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `minio` 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 + MinIO networking (`ENOTFOUND minio`) |
|
||||||
|
|
||||||
## UI & product guides
|
## UI & product guides
|
||||||
|
|
||||||
|
|||||||
+302
-238
@@ -28,17 +28,10 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { FileUpload } from "~/components/forms/file-upload";
|
import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel";
|
||||||
|
import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Plus, Pencil, Trash2, Receipt, Eye } from "lucide-react";
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Receipt,
|
|
||||||
FileText,
|
|
||||||
Paperclip,
|
|
||||||
ExternalLink,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
@@ -70,14 +63,42 @@ const defaultForm: ExpenseFormData = {
|
|||||||
businessId: "",
|
businessId: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatFileSize(bytes: number) {
|
type ExpenseDialogMode = "create" | "view" | "edit";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
function expenseToForm(
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
expense: {
|
||||||
|
date: Date | string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
category: string | null;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean | null;
|
||||||
|
notes: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
businessId: string | null;
|
||||||
|
},
|
||||||
|
defaultBusinessId: string,
|
||||||
|
): ExpenseFormData {
|
||||||
|
return {
|
||||||
|
date: new Date(expense.date),
|
||||||
|
description: expense.description,
|
||||||
|
amount: expense.amount,
|
||||||
|
currency: expense.currency,
|
||||||
|
category: expense.category ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
businessId: expense.businessId ?? defaultBusinessId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExpensesPage() {
|
export default function ExpensesPage() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [dialogMode, setDialogMode] = useState<ExpenseDialogMode>("create");
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
@@ -89,10 +110,6 @@ export default function ExpensesPage() {
|
|||||||
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||||
);
|
);
|
||||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||||
const { data: receipts = [] } = api.expenses.listReceipts.useQuery(
|
|
||||||
{ expenseId: editId! },
|
|
||||||
{ enabled: !!editId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultBusinessId = useMemo(
|
const defaultBusinessId = useMemo(
|
||||||
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
|
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
|
||||||
@@ -100,16 +117,18 @@ export default function ExpensesPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || editId || !defaultBusinessId || form.businessId) return;
|
if (!open || dialogMode !== "create" || !defaultBusinessId || form.businessId)
|
||||||
|
return;
|
||||||
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
|
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
|
||||||
}, [open, editId, defaultBusinessId, form.businessId]);
|
}, [open, dialogMode, defaultBusinessId, form.businessId]);
|
||||||
|
|
||||||
const create = api.expenses.create.useMutation({
|
const create = api.expenses.create.useMutation({
|
||||||
onSuccess: (expense) => {
|
onSuccess: (expense) => {
|
||||||
if (!expense) return;
|
if (!expense) return;
|
||||||
toast.success("Expense added");
|
toast.success("Expense saved — you can now attach receipts");
|
||||||
void utils.expenses.getAll.invalidate();
|
void utils.expenses.getAll.invalidate();
|
||||||
setEditId(expense.id);
|
setEditId(expense.id);
|
||||||
|
setDialogMode("edit");
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -119,6 +138,7 @@ export default function ExpensesPage() {
|
|||||||
void utils.expenses.getAll.invalidate();
|
void utils.expenses.getAll.invalidate();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
|
setDialogMode("create");
|
||||||
setForm(defaultForm);
|
setForm(defaultForm);
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
@@ -131,47 +151,30 @@ export default function ExpensesPage() {
|
|||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
|
||||||
onSuccess: () => {
|
const closeDialog = () => {
|
||||||
toast.success("Receipt uploaded");
|
setOpen(false);
|
||||||
if (editId) {
|
setEditId(null);
|
||||||
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
|
setDialogMode("create");
|
||||||
void utils.expenses.getAll.invalidate();
|
setForm(defaultForm);
|
||||||
}
|
};
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Receipt removed");
|
|
||||||
if (editId) {
|
|
||||||
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
|
|
||||||
void utils.expenses.getAll.invalidate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
|
setDialogMode("create");
|
||||||
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
const handleView = (expense: (typeof expenses)[0]) => {
|
||||||
|
setEditId(expense.id);
|
||||||
|
setDialogMode("view");
|
||||||
|
setForm(expenseToForm(expense, defaultBusinessId));
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||||
setEditId(expense.id);
|
setEditId(expense.id);
|
||||||
setForm({
|
setDialogMode("edit");
|
||||||
date: new Date(expense.date),
|
setForm(expenseToForm(expense, defaultBusinessId));
|
||||||
description: expense.description,
|
|
||||||
amount: expense.amount,
|
|
||||||
currency: expense.currency,
|
|
||||||
category: expense.category ?? "",
|
|
||||||
billable: expense.billable,
|
|
||||||
reimbursable: expense.reimbursable,
|
|
||||||
taxDeductible: expense.taxDeductible ?? false,
|
|
||||||
notes: expense.notes ?? "",
|
|
||||||
clientId: expense.clientId ?? "",
|
|
||||||
businessId: expense.businessId ?? defaultBusinessId,
|
|
||||||
});
|
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
@@ -195,36 +198,6 @@ export default function ExpensesPage() {
|
|||||||
else create.mutate(payload);
|
else create.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReceiptFiles = async (files: File[]) => {
|
|
||||||
if (!editId) {
|
|
||||||
toast.error("Save the expense before uploading receipts");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const file of files) {
|
|
||||||
const data = await 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);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
await uploadReceipt.mutateAsync({
|
|
||||||
expenseId: editId,
|
|
||||||
filename: file.name,
|
|
||||||
mimeType: file.type || "application/octet-stream",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||||
const billableTotal = expenses
|
const billableTotal = expenses
|
||||||
.filter((e) => e.billable)
|
.filter((e) => e.billable)
|
||||||
@@ -232,6 +205,30 @@ export default function ExpensesPage() {
|
|||||||
const deductibleTotal = expenses
|
const deductibleTotal = expenses
|
||||||
.filter((e) => e.taxDeductible)
|
.filter((e) => e.taxDeductible)
|
||||||
.reduce((s, e) => s + e.amount, 0);
|
.reduce((s, e) => s + e.amount, 0);
|
||||||
|
const withReceipts = expenses.filter((e) => e.receiptCount > 0).length;
|
||||||
|
|
||||||
|
const isViewMode = dialogMode === "view";
|
||||||
|
const isEditMode = dialogMode === "edit";
|
||||||
|
const isCreateMode = dialogMode === "create";
|
||||||
|
|
||||||
|
const dialogTitle = isCreateMode
|
||||||
|
? "Add expense"
|
||||||
|
: isViewMode
|
||||||
|
? "View expense"
|
||||||
|
: "Edit expense";
|
||||||
|
|
||||||
|
const businessName =
|
||||||
|
businesses.find((b) => b.id === form.businessId)?.name ??
|
||||||
|
(form.businessId ? "Unknown business" : "Default business");
|
||||||
|
const clientName = form.clientId
|
||||||
|
? (clients.find((c) => c.id === form.clientId)?.name ?? "Unknown client")
|
||||||
|
: "No client";
|
||||||
|
|
||||||
|
const formattedDate = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(form.date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPage>
|
<DashboardPage>
|
||||||
@@ -299,9 +296,9 @@ export default function ExpensesPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
Count
|
With receipts
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
<p className="mt-1 text-2xl font-bold">{withReceipts}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,84 +327,126 @@ export default function ExpensesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<>
|
||||||
{expenses.map((expense) => (
|
<div className="text-muted-foreground hidden border-b px-4 py-2 text-xs font-medium tracking-wide uppercase sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:gap-3">
|
||||||
<div
|
<span>Expense</span>
|
||||||
key={expense.id}
|
<span className="text-center">Receipts</span>
|
||||||
className="flex items-start justify-between gap-3 p-4"
|
<span className="text-right">Amount</span>
|
||||||
>
|
<span className="w-[108px]" />
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="divide-y">
|
||||||
<p className="font-medium">{expense.description}</p>
|
{expenses.map((expense) => (
|
||||||
{expense.billable && (
|
<div
|
||||||
<Badge variant="secondary" className="text-xs">
|
key={expense.id}
|
||||||
Billable
|
role="button"
|
||||||
</Badge>
|
tabIndex={0}
|
||||||
)}
|
onClick={() => handleView(expense)}
|
||||||
{expense.reimbursable && (
|
onKeyDown={(e) => {
|
||||||
<Badge variant="outline" className="text-xs">
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
Reimbursable
|
e.preventDefault();
|
||||||
</Badge>
|
handleView(expense);
|
||||||
)}
|
}
|
||||||
{expense.taxDeductible && (
|
}}
|
||||||
<Badge
|
className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:items-start sm:gap-3"
|
||||||
variant="outline"
|
>
|
||||||
className="border-green-300 text-xs text-green-600"
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Tax Deductible
|
<p className="font-medium">{expense.description}</p>
|
||||||
</Badge>
|
{expense.billable && (
|
||||||
)}
|
<Badge variant="secondary" className="text-xs">
|
||||||
{expense.category && (
|
Billable
|
||||||
<Badge variant="outline" className="text-xs">
|
</Badge>
|
||||||
{expense.category}
|
)}
|
||||||
</Badge>
|
{expense.reimbursable && (
|
||||||
)}
|
<Badge variant="outline" className="text-xs">
|
||||||
{(expense.receipts?.length ?? 0) > 0 && (
|
Reimbursable
|
||||||
<Badge variant="outline" className="text-xs">
|
</Badge>
|
||||||
<Paperclip className="mr-1 h-3 w-3" />
|
)}
|
||||||
{expense.receipts?.length}
|
{expense.taxDeductible && (
|
||||||
</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-300 text-xs text-green-600"
|
||||||
|
>
|
||||||
|
Tax Deductible
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{expense.category && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{expense.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(expense.date))}
|
||||||
|
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||||
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
|
</p>
|
||||||
|
{expense.notes && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{expense.notes}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
<div
|
||||||
month: "short",
|
className="flex items-center sm:justify-center"
|
||||||
day: "numeric",
|
onClick={(e) => e.stopPropagation()}
|
||||||
year: "numeric",
|
>
|
||||||
}).format(new Date(expense.date))}
|
<span className="text-muted-foreground mr-2 text-xs sm:hidden">
|
||||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
Receipts
|
||||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
</span>
|
||||||
</p>
|
<ExpenseReceiptIndicator
|
||||||
{expense.notes && (
|
expenseId={expense.id}
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
receiptCount={expense.receiptCount}
|
||||||
{expense.notes}
|
receiptPreview={expense.receiptPreview}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between sm:contents">
|
||||||
|
<p className="font-semibold sm:text-right">
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
<div
|
||||||
|
className="flex flex-shrink-0 items-center gap-1 sm:gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handleView(expense)}
|
||||||
|
title="View expense"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handleEdit(expense)}
|
||||||
|
title="Edit expense"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive h-8 w-8 p-0"
|
||||||
|
onClick={() => setDeleteId(expense.id)}
|
||||||
|
title="Delete expense"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
))}
|
||||||
<p className="font-semibold">
|
</div>
|
||||||
{formatCurrency(expense.amount, expense.currency)}
|
</>
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => handleEdit(expense)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive h-8 w-8 p-0"
|
|
||||||
onClick={() => setDeleteId(expense.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -418,15 +457,95 @@ export default function ExpensesPage() {
|
|||||||
setOpen(next);
|
setOpen(next);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
|
setDialogMode("create");
|
||||||
setForm(defaultForm);
|
setForm(defaultForm);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
|
{isCreateMode && (
|
||||||
|
<DialogDescription>
|
||||||
|
Fill in the details below. You can attach receipts after saving.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
|
{isViewMode ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1 sm:col-span-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{form.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Amount
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{formatCurrency(form.amount, form.currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Date
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{formattedDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Category
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{form.category || "None"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Business
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{businessName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Client
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{clientName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Flags
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{form.billable ? (
|
||||||
|
<Badge variant="secondary">Billable</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Not billable</Badge>
|
||||||
|
)}
|
||||||
|
{form.reimbursable ? (
|
||||||
|
<Badge variant="outline">Reimbursable</Badge>
|
||||||
|
) : null}
|
||||||
|
{form.taxDeductible ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-300 text-green-600"
|
||||||
|
>
|
||||||
|
Tax deductible
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.notes ? (
|
||||||
|
<div className="space-y-1 sm:col-span-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Notes
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{form.notes}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Description *</Label>
|
<Label>Description *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -584,82 +703,25 @@ export default function ExpensesPage() {
|
|||||||
placeholder="Additional details…"
|
placeholder="Additional details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
{editId ? (
|
|
||||||
<div className="space-y-3 border-t pt-4">
|
|
||||||
<Label>Receipts</Label>
|
|
||||||
{receipts.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{receipts.map((receipt) => {
|
|
||||||
const isImage = receipt.mimeType.startsWith("image/");
|
|
||||||
const url = `/api/receipts/${receipt.id}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={receipt.id}
|
|
||||||
className="flex items-center gap-3 rounded-md border p-2"
|
|
||||||
>
|
|
||||||
{isImage ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={receipt.originalFilename}
|
|
||||||
className="h-12 w-12 rounded object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded">
|
|
||||||
<FileText className="text-muted-foreground h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium">
|
|
||||||
{receipt.originalFilename}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{formatFileSize(receipt.sizeBytes)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<a href={url} target="_blank" rel="noreferrer">
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() =>
|
|
||||||
deleteReceipt.mutate({ id: receipt.id })
|
|
||||||
}
|
|
||||||
disabled={deleteReceipt.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FileUpload
|
|
||||||
onFilesSelected={(files) => void handleReceiptFiles(files)}
|
|
||||||
accept={{
|
|
||||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
|
||||||
"application/pdf": [".pdf"],
|
|
||||||
}}
|
|
||||||
maxFiles={5}
|
|
||||||
maxSize={10 * 1024 * 1024}
|
|
||||||
disabled={uploadReceipt.isPending}
|
|
||||||
placeholder="Drop receipts here"
|
|
||||||
description="Images or PDF, up to 10MB each"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Save the expense first, then you can attach receipts.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
{isViewMode ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setDialogMode("edit")}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -668,10 +730,12 @@ export default function ExpensesPage() {
|
|||||||
>
|
>
|
||||||
{create.isPending || update.isPending
|
{create.isPending || update.isPending
|
||||||
? "Saving…"
|
? "Saving…"
|
||||||
: editId
|
: isEditMode
|
||||||
? "Update"
|
? "Update"
|
||||||
: "Add Expense"}
|
: "Save & add receipts"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -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,44 @@
|
|||||||
|
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);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export const env = createEnv({
|
|||||||
S3_ACCESS_KEY: z.string().optional(),
|
S3_ACCESS_KEY: z.string().optional(),
|
||||||
S3_SECRET_KEY: z.string().optional(),
|
S3_SECRET_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
|
S3_FORCE_PATH_STYLE: optionalEnvBoolean(),
|
||||||
// SSO / Authentik (optional)
|
// SSO / Authentik (optional)
|
||||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||||
@@ -87,6 +88,7 @@ export const env = createEnv({
|
|||||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
||||||
S3_REGION: process.env.S3_REGION,
|
S3_REGION: process.env.S3_REGION,
|
||||||
|
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
|
||||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||||
|
|||||||
+76
-19
@@ -21,6 +21,56 @@ type S3Module = typeof import("@aws-sdk/client-s3");
|
|||||||
|
|
||||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||||
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||||
|
let s3DnsHintLogged = false;
|
||||||
|
let s3BareMinioHintLogged = false;
|
||||||
|
|
||||||
|
function shouldForcePathStyle(): boolean {
|
||||||
|
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
|
||||||
|
if (override === "true" || override === "1") return true;
|
||||||
|
if (override === "false" || override === "0") return false;
|
||||||
|
return Boolean(process.env.S3_ENDPOINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logBareMinioEndpointHint(): void {
|
||||||
|
if (s3BareMinioHintLogged || process.env.NODE_ENV !== "production") return;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT;
|
||||||
|
if (!endpoint) return;
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(endpoint);
|
||||||
|
if (hostname !== "minio") return;
|
||||||
|
s3BareMinioHintLogged = true;
|
||||||
|
console.warn(
|
||||||
|
"[object-storage] S3_ENDPOINT hostname is bare 'minio'. " +
|
||||||
|
"That only resolves inside a single Docker Compose stack. " +
|
||||||
|
"Coolify Application + separate MinIO compose: set S3_ENDPOINT to " +
|
||||||
|
"SERVICE_URL_MINIO_9000 (public domain) or http://minio-<resource-uuid>:9000. " +
|
||||||
|
"See docs/COOLIFY.md.",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Invalid URL — env validation or S3 client will surface it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logS3DnsHint(error: unknown): void {
|
||||||
|
if (s3DnsHintLogged) return;
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
|
||||||
|
s3DnsHintLogged = true;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
|
||||||
|
console.error(
|
||||||
|
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
|
||||||
|
"Separate Coolify stacks cannot resolve bare 'minio' — use the internal hostname from the MinIO resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
logS3DnsHint(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getS3() {
|
async function getS3() {
|
||||||
if (!s3ModulePromise) {
|
if (!s3ModulePromise) {
|
||||||
@@ -28,6 +78,7 @@ async function getS3() {
|
|||||||
}
|
}
|
||||||
const mod = await s3ModulePromise;
|
const mod = await s3ModulePromise;
|
||||||
if (!s3Client) {
|
if (!s3Client) {
|
||||||
|
logBareMinioEndpointHint();
|
||||||
s3Client = new mod.S3Client({
|
s3Client = new mod.S3Client({
|
||||||
region: process.env.S3_REGION ?? "us-east-1",
|
region: process.env.S3_REGION ?? "us-east-1",
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
@@ -35,8 +86,8 @@ async function getS3() {
|
|||||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||||
},
|
},
|
||||||
// Required for MinIO and most S3-compatible endpoints.
|
// Required for MinIO and most S3-compatible endpoints (including HTTPS proxies).
|
||||||
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
|
forcePathStyle: shouldForcePathStyle(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { client: s3Client, ...mod };
|
return { client: s3Client, ...mod };
|
||||||
@@ -53,13 +104,15 @@ export async function putObject(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isS3Configured()) {
|
if (isS3Configured()) {
|
||||||
const { client, PutObjectCommand } = await getS3();
|
const { client, PutObjectCommand } = await getS3();
|
||||||
await client.send(
|
await withS3Diagnostics(() =>
|
||||||
new PutObjectCommand({
|
client.send(
|
||||||
Bucket: process.env.S3_BUCKET!,
|
new PutObjectCommand({
|
||||||
Key: key,
|
Bucket: process.env.S3_BUCKET!,
|
||||||
Body: body,
|
Key: key,
|
||||||
ContentType: contentType,
|
Body: body,
|
||||||
}),
|
ContentType: contentType,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,11 +125,13 @@ export async function putObject(
|
|||||||
export async function getObject(key: string): Promise<Buffer> {
|
export async function getObject(key: string): Promise<Buffer> {
|
||||||
if (isS3Configured()) {
|
if (isS3Configured()) {
|
||||||
const { client, GetObjectCommand } = await getS3();
|
const { client, GetObjectCommand } = await getS3();
|
||||||
const response = await client.send(
|
const response = await withS3Diagnostics(() =>
|
||||||
new GetObjectCommand({
|
client.send(
|
||||||
Bucket: process.env.S3_BUCKET!,
|
new GetObjectCommand({
|
||||||
Key: key,
|
Bucket: process.env.S3_BUCKET!,
|
||||||
}),
|
Key: key,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const bytes = await response.Body?.transformToByteArray();
|
const bytes = await response.Body?.transformToByteArray();
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
@@ -91,11 +146,13 @@ export async function getObject(key: string): Promise<Buffer> {
|
|||||||
export async function deleteObject(key: string): Promise<void> {
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
if (isS3Configured()) {
|
if (isS3Configured()) {
|
||||||
const { client, DeleteObjectCommand } = await getS3();
|
const { client, DeleteObjectCommand } = await getS3();
|
||||||
await client.send(
|
await withS3Diagnostics(() =>
|
||||||
new DeleteObjectCommand({
|
client.send(
|
||||||
Bucket: process.env.S3_BUCKET!,
|
new DeleteObjectCommand({
|
||||||
Key: key,
|
Bucket: process.env.S3_BUCKET!,
|
||||||
}),
|
Key: key,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc, inArray } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import {
|
import {
|
||||||
expenses,
|
expenses,
|
||||||
@@ -132,16 +132,67 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
conditions.push(eq(expenses.businessId, input.businessId));
|
conditions.push(eq(expenses.businessId, input.businessId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ctx.db.query.expenses.findMany({
|
const rows = await ctx.db.query.expenses.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
client: true,
|
||||||
business: true,
|
business: true,
|
||||||
invoice: true,
|
invoice: true,
|
||||||
receipts: true,
|
|
||||||
},
|
},
|
||||||
orderBy: [desc(expenses.date)],
|
orderBy: [desc(expenses.date)],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expenseIds = rows.map((e) => e.id);
|
||||||
|
if (expenseIds.length === 0) return [];
|
||||||
|
|
||||||
|
const receiptMeta = await ctx.db
|
||||||
|
.select({
|
||||||
|
expenseId: expenseReceipts.expenseId,
|
||||||
|
id: expenseReceipts.id,
|
||||||
|
mimeType: expenseReceipts.mimeType,
|
||||||
|
originalFilename: expenseReceipts.originalFilename,
|
||||||
|
createdAt: expenseReceipts.createdAt,
|
||||||
|
})
|
||||||
|
.from(expenseReceipts)
|
||||||
|
.where(inArray(expenseReceipts.expenseId, expenseIds))
|
||||||
|
.orderBy(desc(expenseReceipts.createdAt));
|
||||||
|
|
||||||
|
const receiptStats = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
receiptCount: number;
|
||||||
|
receiptPreview: {
|
||||||
|
id: string;
|
||||||
|
mimeType: string;
|
||||||
|
originalFilename: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const receipt of receiptMeta) {
|
||||||
|
const existing = receiptStats.get(receipt.expenseId);
|
||||||
|
if (existing) {
|
||||||
|
existing.receiptCount += 1;
|
||||||
|
} else {
|
||||||
|
receiptStats.set(receipt.expenseId, {
|
||||||
|
receiptCount: 1,
|
||||||
|
receiptPreview: {
|
||||||
|
id: receipt.id,
|
||||||
|
mimeType: receipt.mimeType,
|
||||||
|
originalFilename: receipt.originalFilename,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((expense) => {
|
||||||
|
const stats = receiptStats.get(expense.id);
|
||||||
|
return {
|
||||||
|
...expense,
|
||||||
|
receiptCount: stats?.receiptCount ?? 0,
|
||||||
|
receiptPreview: stats?.receiptPreview ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user