Improve expenses receipts UX and Coolify MinIO deployment.

Extract receipt UI components, add view/edit/create dialog modes with list receipt previews, add docker-compose.coolify.yml and clearer COOLIFY/S3 path-style guidance for Application + MinIO setups.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-27 00:47:59 -04:00
co-authored by Cursor
parent 5e6e51337a
commit c267b3e1c8
15 changed files with 1236 additions and 361 deletions
+6 -10
View File
@@ -136,17 +136,12 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# S3_ENDPOINT — who can reach MinIO? # S3_ENDPOINT — who can reach MinIO?
# • Host dev (bun dev + docker-compose.dev.yml MinIO on the host): localhost:9000 # • 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) # • App in Docker (docker-compose.yml): http://minio:9000 (Compose service name)
# • Coolify — see docs/COOLIFY.md for full steps. Summary: # • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.yml (app+db+minio); do not override S3_ENDPOINT. # - Best: one Compose resource with docker-compose.coolify.yml (app+db+minio).
# - App + separate MinIO stack: ENOTFOUND minio means the app is not on MinIO's Docker network. # - Application + separate MinIO: ENOTFOUND minio → set S3_ENDPOINT to
# Fix: Beevoice Application → enable "Connect to Predefined Network" (same destination as MinIO), # SERVICE_URL_MINIO_9000 (public domain) OR http://minio-<resource-uuid>:9000
# set S3_ENDPOINT=http://<internal-host-from-minio-ui>:9000 (often NOT bare "minio"). # 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. # - NEVER use localhost in production — inside the app container that is the app, not MinIO.
# Troubleshooting getaddrinfo ENOTFOUND minio:
# 1) Confirm Beevoice and MinIO are same Coolify project/destination
# 2) Enable Connect to Predefined Network on Beevoice; redeploy
# 3) Copy hostname from MinIO resource internal URL → S3_ENDPOINT (http://HOST:9000)
# 4) Or deploy docker-compose.yml as a single stack instead
# #
# 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
@@ -154,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.
+8 -1
View File
@@ -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 |
+22 -7
View File
@@ -2,23 +2,38 @@
# #
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml # Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml
# #
# Beevoice (Application) cannot use S3_ENDPOINT=http://minio:9000 unless it shares # ── Fix ENOTFOUND minio on a Beevoice Application (pick ONE) ─────────────────
# this stack's Docker network AND that hostname resolves (usually it does not across
# separate Coolify resources). See docs/COOLIFY.md — copy the internal hostname from
# the MinIO resource UI into Beevoice's S3_ENDPOINT and enable "Connect to Predefined
# Network" on the Beevoice app.
# #
# Recommended alternative: deploy full docker-compose.yml as one Compose resource (app # A) Public MinIO URL (most reliable — no shared Docker network required)
# + db + minio) so S3_ENDPOINT=http://minio:9000 works without extra networking. # 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: services:
minio: minio:
image: minio/minio:latest 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}
SERVICE_FQDN_MINIO_9000:
SERVICE_FQDN_MINIO_9001:
volumes: volumes:
- beenvoice_minio_data:/data - beenvoice_minio_data:/data
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
expose:
- "9000"
- "9001"
healthcheck: healthcheck:
test: ["CMD", "mc", "ready", "local"] test: ["CMD", "mc", "ready", "local"]
interval: 5s interval: 5s
+111
View File
@@ -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:
+3 -2
View File
@@ -1,7 +1,8 @@
# 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 this file as ONE Docker Compose resource so S3_ENDPOINT=http://minio:9000 works. # Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
# Separate Application + MinIO stacks need shared networking — see docs/COOLIFY.md. # 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:
+82 -98
View File
@@ -8,144 +8,128 @@ Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://minio:9000` work? | | Setup | Does `http://minio:9000` work? |
|-------|-------------------------------| |-------|-------------------------------|
| Single `docker-compose.yml` stack (app + minio together) | Yes — Compose service name `minio` | | Single Compose stack (app + minio together) | Yes — Compose service name `minio` |
| Beevoice **Application** + MinIO **separate Compose** resource | **No** — each Coolify resource gets its own network by default | | Beevoice **Application** + MinIO **separate Compose** | **No** — each resource has its own network by default |
| Both resources share a Coolify **destination** network + correct hostname | Yes — but hostname is often **not** bare `minio` | | 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 internal network. Node's DNS lookup returns `ENOTFOUND minio`. 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. Also avoid `http://localhost:9000` inside the app container — that points at the app itself, not MinIO.
--- ---
## Recommended: Option C — one Compose stack (simplest) ## Quick fix — keep Beevoice as Application + separate MinIO compose
Deploy the repo's full [`docker-compose.yml`](../docker-compose.yml) as **one** Coolify **Docker Compose** resource (app + Postgres + MinIO + minio-init). Use this if you are **not** migrating to a single Compose stack today.
1. Coolify → **New Resource****Docker Compose** ### Path A — public MinIO URL (recommended, works without shared Docker network)
2. Point at this repo; compose file: `docker-compose.yml`
3. Set env vars from [`.env.example`](../.env.example) (`AUTH_SECRET`, `BETTER_AUTH_URL`, `NEXT_PUBLIC_APP_URL`, etc.)
4. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://minio:9000` for the app service automatically
5. Redeploy
All services share one Compose network; `minio` resolves correctly. 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.
## Option A — separate resources, shared Coolify network 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:
Use when Beevoice stays a standalone **Application** (Dockerfile) and MinIO is a separate Compose resource.
### 1. Same Coolify project and destination
Put both resources in the **same Coolify project** and deploy them to the **same destination** (same Docker network / server).
### 2. Connect the Beevoice app to that network
On the **Beevoice Application** resource:
1. Open **Advanced** (or network settings)
2. Enable **Connect to Predefined Network**
3. Select the **same destination/network** as the MinIO stack
4. **Redeploy** the app (required after toggling network)
The MinIO stack does **not** need this option — only the service that **initiates** connections (Beevoice) needs it.
### 3. Set `S3_ENDPOINT` to the real internal hostname
Bare `minio` usually still fails across separate Coolify resources. Use the hostname Coolify assigns on the shared network:
1. Open the **MinIO Compose** resource in Coolify
2. Find the **internal URL** / connection info (eye icon next to internal connection string)
3. Copy the **hostname** from that URL (not `localhost`, not bare `minio` unless you verified it resolves)
Typical patterns:
| What you see | Use as `S3_ENDPOINT` |
|--------------|----------------------|
| Internal URL host `minio-abc123def456` | `http://minio-abc123def456:9000` |
| Container name `x8k2j4...` (random id) | `http://x8k2j4...:9000` |
| Same compose stack only | `http://minio:9000` |
On the Coolify server you can confirm:
```bash
# List MinIO containers
docker ps --filter name=minio
# See DNS aliases on the shared network (replace CONTAINER and NETWORK)
docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}' | jq
```
Set on the **Beevoice Application** env:
```env ```env
S3_ENDPOINT=http://<hostname-from-step-3>:9000 S3_ENDPOINT=https://s3.yourdomain.com
S3_BUCKET=beenvoice-receipts S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<same as MINIO_ROOT_USER> S3_ACCESS_KEY=<same as MINIO_ROOT_USER>
S3_SECRET_KEY=<same as MINIO_ROOT_PASSWORD> S3_SECRET_KEY=<same as MINIO_ROOT_PASSWORD>
S3_REGION=us-east-1 S3_REGION=us-east-1
``` ```
Redeploy Beevoice after changing env. 5. **Redeploy Beevoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
### 4. Enable on MinIO stack too (only if A still fails) `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.
If the app still cannot resolve the hostname, enable **Connect to Predefined Network** on the **MinIO Compose** resource as well (same destination), redeploy MinIO, then re-check the internal URL — Coolify may expose a `minio-<resource-id>` alias on the shared network. ### Path B — internal Docker DNS (same destination, no public MinIO domain)
--- Use when you want MinIO API traffic to stay on the Docker network.
## Option B — internal URL from Coolify UI (quick fix) 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**.
Same as Option A step 3, without re-architecting: 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).
1. MinIO resource → copy **internal** hostname (from internal URL field) 5. Set on Beevoice Application:
2. Beevoice Application → `S3_ENDPOINT=http://<that-hostname>:9000`
3. Enable **Connect to Predefined Network** on Beevoice if not already
4. Redeploy Beevoice
If DNS still fails, the app is not on the network where that hostname is registered — go back to Option A or use Option C.
---
## Option D — public / external MinIO URL (fallback)
If internal Docker DNS cannot be made to work:
```env ```env
S3_ENDPOINT=https://minio.yourdomain.com S3_ENDPOINT=http://minio-<MINIO_RESOURCE_UUID>:9000
``` ```
Expose MinIO API (port 9000) via Coolify proxy or a public domain. Less ideal (traffic leaves the Docker network, TLS/path-style config may need tuning) but avoids internal DNS entirely. 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
```
--- ---
## Separate MinIO-only Compose file ## Recommended long-term — one Compose stack
[`docker-compose.coolify-minio.yml`](../docker-compose.coolify-minio.yml) deploys only MinIO + bucket init for a dedicated Coolify Compose resource. Pair it with a Beevoice Application using Option A or B. 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.
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your Coolify 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. 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 |
--- ---
## Checklist ## Compose file reference
- [ ] Beevoice and MinIO in the same Coolify **project** | File | Purpose |
- [ ] Same **destination** / server |------|---------|
- [ ] Beevoice Application: **Connect to Predefined Network** enabled (when MinIO is a separate resource) | [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
- [ ] `S3_ENDPOINT` uses internal hostname from Coolify UI — not `localhost`, not unverified `minio` | [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match MinIO `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | [`docker-compose.coolify-minio.yml`](../docker-compose.coolify-minio.yml) | MinIO + bucket init only; pair with Beevoice Application (Path A or B above) |
- [ ] `S3_BUCKET` exists ( `minio-init` in compose creates `beenvoice-receipts` by default)
- [ ] Redeployed after env or network changes 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 ## Verify from the Beevoice container
```bash ```bash
# Shell into Beevoice app container on Coolify server # Shell into Beevoice app container on the Coolify server
docker exec -it <beenvoice-container> sh docker exec -it <beenvoice-container> sh
# Replace HOST with your S3_ENDPOINT hostname (no scheme/port) # Path A — public URL (include scheme; path is /minio/health/live on API port)
wget -qO- "http://HOST:9000/minio/health/live" || curl -sf "http://HOST:9000/minio/health/live" 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 before debugging app code. 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.
+302 -238
View File
@@ -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>
&ldquo;{receipt.originalFilename}&rdquo; will be permanently
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteReceipt.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteReceipt.isPending}
onClick={(e) => {
e.preventDefault();
deleteReceipt.mutate({ id: receipt.id });
}}
>
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useRef, useState } from "react";
import { Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Label } from "~/components/ui/label";
import { FileUpload } from "~/components/forms/file-upload";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import {
fileToBase64,
RECEIPT_ACCEPT,
RECEIPT_MAX_SIZE,
RECEIPT_UPLOAD_HINT,
} from "~/components/expenses/receipt-utils";
interface ExpenseReceiptsPanelProps {
expenseId: string | null;
readOnly?: boolean;
}
export function ExpenseReceiptsPanel({
expenseId,
readOnly = false,
}: ExpenseReceiptsPanelProps) {
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const [uploadKey, setUploadKey] = useState(0);
const processedFileCountRef = useRef(0);
const utils = api.useUtils();
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId: expenseId! },
{ enabled: !!expenseId },
);
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => {
if (expenseId) {
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
}
},
onError: (e) => toast.error(e.message),
});
const handleFiles = async (files: File[]) => {
if (!expenseId || files.length === 0) return;
const newFiles = files.slice(processedFileCountRef.current);
processedFileCountRef.current = files.length;
if (newFiles.length === 0) return;
let uploaded = 0;
for (const file of newFiles) {
try {
const data = await fileToBase64(file);
await uploadReceipt.mutateAsync({
expenseId,
filename: file.name,
mimeType: file.type || "application/octet-stream",
data,
});
uploaded++;
} catch (error) {
const message =
error instanceof Error ? error.message : "Upload failed";
toast.error(`${file.name}: ${message}`);
}
}
if (uploaded > 0) {
toast.success(
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
);
processedFileCountRef.current = 0;
setUploadKey((k) => k + 1);
}
};
if (!expenseId) {
return (
<div className="space-y-2 border-t pt-4">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
</Label>
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
Save the expense first, then drag and drop receipts here.
</div>
</div>
);
}
return (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
{receipts.length > 0 && (
<span className="text-muted-foreground text-xs font-normal">
({receipts.length})
</span>
)}
</Label>
{uploadReceipt.isPending && (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading
</span>
)}
</div>
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : receipts.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
{readOnly
? "No receipts attached."
: "No receipts yet. Drop images or PDFs below."}
</div>
) : (
<div className="space-y-2">
{receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={setViewerReceipt}
readOnly={readOnly}
/>
))}
</div>
)}
{!readOnly && (
<FileUpload
key={`${expenseId}-${uploadKey}`}
onFilesSelected={(files) => void handleFiles(files)}
accept={RECEIPT_ACCEPT}
maxFiles={5}
maxSize={RECEIPT_MAX_SIZE}
disabled={uploadReceipt.isPending}
placeholder="Drop receipts here or tap to browse"
description={RECEIPT_UPLOAD_HINT}
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
/>
)}
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
</div>
);
}
+44
View File
@@ -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>
);
}
+2
View File
@@ -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,
+31 -2
View File
@@ -22,6 +22,34 @@ 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 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 { function logS3DnsHint(error: unknown): void {
if (s3DnsHintLogged) return; if (s3DnsHintLogged) return;
@@ -50,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,
@@ -57,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 };
+54 -3
View File
@@ -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