diff --git a/.env.example b/.env.example index 43b64d3..52c087d 100644 --- a/.env.example +++ b/.env.example @@ -136,17 +136,12 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # 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 for full steps. Summary: -# - Best: one Compose resource with docker-compose.yml (app+db+minio); do not override S3_ENDPOINT. -# - App + separate MinIO stack: ENOTFOUND minio means the app is not on MinIO's Docker network. -# Fix: Beevoice Application → enable "Connect to Predefined Network" (same destination as MinIO), -# set S3_ENDPOINT=http://:9000 (often NOT bare "minio"). +# • 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-: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. -# 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`): S3_ENDPOINT=http://localhost:9000 @@ -154,6 +149,7 @@ S3_BUCKET=beenvoice-receipts S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin 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 # automatically. MINIO_ROOT_* below must match S3_ACCESS_KEY / S3_SECRET_KEY. diff --git a/README.md b/README.md index d870f0c..2a845cb 100644 --- a/README.md +++ b/README.md @@ -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`. +### 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 Registration is **enabled** by default. To block new email/password accounts: @@ -190,7 +194,9 @@ beenvoice-web/ ├── src/lib/ # auth, PDF, email, branding helpers ├── drizzle/ # SQL migrations ├── 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) └── docs/ # Architecture and UI guides ``` @@ -250,6 +256,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation. | Doc | Contents | |-----|----------| | [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 | | [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development | diff --git a/docker-compose.coolify-minio.yml b/docker-compose.coolify-minio.yml index 4bed91c..14c5d95 100644 --- a/docker-compose.coolify-minio.yml +++ b/docker-compose.coolify-minio.yml @@ -2,23 +2,38 @@ # # Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml # -# Beevoice (Application) cannot use S3_ENDPOINT=http://minio:9000 unless it shares -# 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. +# ── Fix ENOTFOUND minio on a Beevoice Application (pick ONE) ───────────────── # -# Recommended alternative: deploy full docker-compose.yml as one Compose resource (app -# + db + minio) so S3_ENDPOINT=http://minio:9000 works without extra networking. +# 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= → 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-: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 diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml new file mode 100644 index 0000000..4da285e --- /dev/null +++ b/docker-compose.coolify.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index e0483c4..06c9b37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ # 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. -# Separate Application + MinIO stacks need shared networking — see docs/COOLIFY.md. +# 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 # the existing local image and will NOT include new code. Use: diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md index 064b9ea..9459c45 100644 --- a/docs/COOLIFY.md +++ b/docs/COOLIFY.md @@ -8,144 +8,128 @@ Docker DNS resolves service names **only inside the same Docker network**. | Setup | Does `http://minio:9000` work? | |-------|-------------------------------| -| Single `docker-compose.yml` 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 | -| Both resources share a Coolify **destination** network + correct hostname | Yes — but hostname is often **not** bare `minio` | +| 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-`**, 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. --- -## 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** -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 +### Path A — public MinIO URL (recommended, works without shared Docker network) -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. ---- - -## Option A — separate resources, shared Coolify network - -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: +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=http://:9000 +S3_ENDPOINT=https://s3.yourdomain.com S3_BUCKET=beenvoice-receipts S3_ACCESS_KEY= S3_SECRET_KEY= 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-` 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) - -Same as Option A step 3, without re-architecting: - -1. MinIO resource → copy **internal** hostname (from internal URL field) -2. Beevoice Application → `S3_ENDPOINT=http://: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: +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=https://minio.yourdomain.com +S3_ENDPOINT=http://minio-: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-` form or Path A). + +6. Match credentials and bucket: + +```env +S3_BUCKET=beenvoice-receipts +S3_ACCESS_KEY= +S3_SECRET_KEY= +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** -- [ ] Same **destination** / server -- [ ] Beevoice Application: **Connect to Predefined Network** enabled (when MinIO is a separate resource) -- [ ] `S3_ENDPOINT` uses internal hostname from Coolify UI — not `localhost`, not unverified `minio` -- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match MinIO `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` -- [ ] `S3_BUCKET` exists ( `minio-init` in compose creates `beenvoice-receipts` by default) -- [ ] Redeployed after env or network changes +| 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-: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 Coolify server +# Shell into Beevoice app container on the Coolify server docker exec -it sh -# Replace HOST with your S3_ENDPOINT hostname (no scheme/port) -wget -qO- "http://HOST:9000/minio/health/live" || curl -sf "http://HOST:9000/minio/health/live" +# 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-:9000/minio/health/live" || curl -sf "http://minio-: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. diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index 90b7e64..f310795 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -28,17 +28,10 @@ import { } from "~/components/ui/select"; import { DatePicker } from "~/components/ui/date-picker"; 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 { - Plus, - Pencil, - Trash2, - Receipt, - FileText, - Paperclip, - ExternalLink, -} from "lucide-react"; +import { Plus, Pencil, Trash2, Receipt, Eye } from "lucide-react"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; @@ -70,14 +63,42 @@ const defaultForm: ExpenseFormData = { businessId: "", }; -function formatFileSize(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`; +type ExpenseDialogMode = "create" | "view" | "edit"; + +function expenseToForm( + expense: { + date: Date | string; + description: string; + amount: number; + currency: string; + category: string | null; + billable: boolean; + reimbursable: boolean; + taxDeductible: boolean | null; + notes: string | null; + clientId: string | null; + businessId: string | null; + }, + defaultBusinessId: string, +): ExpenseFormData { + return { + date: new Date(expense.date), + description: expense.description, + amount: expense.amount, + currency: expense.currency, + category: expense.category ?? "", + billable: expense.billable, + reimbursable: expense.reimbursable, + taxDeductible: expense.taxDeductible ?? false, + notes: expense.notes ?? "", + clientId: expense.clientId ?? "", + businessId: expense.businessId ?? defaultBusinessId, + }; } export default function ExpensesPage() { const [open, setOpen] = useState(false); + const [dialogMode, setDialogMode] = useState("create"); const [editId, setEditId] = useState(null); const [form, setForm] = useState(defaultForm); const [deleteId, setDeleteId] = useState(null); @@ -89,10 +110,6 @@ export default function ExpensesPage() { businessFilter === "all" ? undefined : { businessId: businessFilter }, ); const { data: clients = [] } = api.clients.getAll.useQuery(); - const { data: receipts = [] } = api.expenses.listReceipts.useQuery( - { expenseId: editId! }, - { enabled: !!editId }, - ); const defaultBusinessId = useMemo( () => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "", @@ -100,16 +117,18 @@ export default function ExpensesPage() { ); useEffect(() => { - if (!open || editId || !defaultBusinessId || form.businessId) return; + if (!open || dialogMode !== "create" || !defaultBusinessId || form.businessId) + return; setForm((prev) => ({ ...prev, businessId: defaultBusinessId })); - }, [open, editId, defaultBusinessId, form.businessId]); + }, [open, dialogMode, defaultBusinessId, form.businessId]); const create = api.expenses.create.useMutation({ onSuccess: (expense) => { if (!expense) return; - toast.success("Expense added"); + toast.success("Expense saved — you can now attach receipts"); void utils.expenses.getAll.invalidate(); setEditId(expense.id); + setDialogMode("edit"); }, onError: (e) => toast.error(e.message), }); @@ -119,6 +138,7 @@ export default function ExpensesPage() { void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); + setDialogMode("create"); setForm(defaultForm); }, onError: (e) => toast.error(e.message), @@ -131,47 +151,30 @@ export default function ExpensesPage() { }, onError: (e) => toast.error(e.message), }); - const uploadReceipt = api.expenses.uploadReceipt.useMutation({ - onSuccess: () => { - toast.success("Receipt uploaded"); - if (editId) { - void utils.expenses.listReceipts.invalidate({ expenseId: editId }); - void utils.expenses.getAll.invalidate(); - } - }, - 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 closeDialog = () => { + setOpen(false); + setEditId(null); + setDialogMode("create"); + setForm(defaultForm); + }; const handleOpen = () => { setEditId(null); + setDialogMode("create"); setForm({ ...defaultForm, businessId: defaultBusinessId }); setOpen(true); }; + const handleView = (expense: (typeof expenses)[0]) => { + setEditId(expense.id); + setDialogMode("view"); + setForm(expenseToForm(expense, defaultBusinessId)); + setOpen(true); + }; const handleEdit = (expense: (typeof expenses)[0]) => { setEditId(expense.id); - setForm({ - 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, - }); + setDialogMode("edit"); + setForm(expenseToForm(expense, defaultBusinessId)); setOpen(true); }; const handleSubmit = () => { @@ -195,36 +198,6 @@ export default function ExpensesPage() { 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((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 billableTotal = expenses .filter((e) => e.billable) @@ -232,6 +205,30 @@ export default function ExpensesPage() { const deductibleTotal = expenses .filter((e) => e.taxDeductible) .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 ( @@ -299,9 +296,9 @@ export default function ExpensesPage() {

- Count + With receipts

-

{expenses.length}

+

{withReceipts}

@@ -330,84 +327,126 @@ export default function ExpensesPage() { } /> ) : ( -
- {expenses.map((expense) => ( -
-
-
-

{expense.description}

- {expense.billable && ( - - Billable - - )} - {expense.reimbursable && ( - - Reimbursable - - )} - {expense.taxDeductible && ( - - Tax Deductible - - )} - {expense.category && ( - - {expense.category} - - )} - {(expense.receipts?.length ?? 0) > 0 && ( - - - {expense.receipts?.length} - + <> +
+ Expense + Receipts + Amount + +
+
+ {expenses.map((expense) => ( +
handleView(expense)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleView(expense); + } + }} + className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:items-start sm:gap-3" + > +
+
+

{expense.description}

+ {expense.billable && ( + + Billable + + )} + {expense.reimbursable && ( + + Reimbursable + + )} + {expense.taxDeductible && ( + + Tax Deductible + + )} + {expense.category && ( + + {expense.category} + + )} +
+

+ {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}` : ""} +

+ {expense.notes && ( +

+ {expense.notes} +

)}
-

- {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}` : ""} -

- {expense.notes && ( -

- {expense.notes} + +

e.stopPropagation()} + > + + Receipts + + +
+ +
+

+ {formatCurrency(expense.amount, expense.currency)}

- )} +
e.stopPropagation()} + > + + + +
+
-
-

- {formatCurrency(expense.amount, expense.currency)} -

- - -
-
- ))} -
+ ))} +
+ )} @@ -418,15 +457,95 @@ export default function ExpensesPage() { setOpen(next); if (!next) { setEditId(null); + setDialogMode("create"); setForm(defaultForm); } }} > - + - {editId ? "Edit Expense" : "Add Expense"} + {dialogTitle} + {isCreateMode && ( + + Fill in the details below. You can attach receipts after saving. + + )}
+ {isViewMode ? ( +
+
+

+ Description +

+

{form.description}

+
+
+

+ Amount +

+

+ {formatCurrency(form.amount, form.currency)} +

+
+
+

+ Date +

+

{formattedDate}

+
+
+

+ Category +

+

{form.category || "None"}

+
+
+

+ Business +

+

{businessName}

+
+
+

+ Client +

+

{clientName}

+
+
+

+ Flags +

+
+ {form.billable ? ( + Billable + ) : ( + Not billable + )} + {form.reimbursable ? ( + Reimbursable + ) : null} + {form.taxDeductible ? ( + + Tax deductible + + ) : null} +
+
+ {form.notes ? ( +
+

+ Notes +

+

{form.notes}

+
+ ) : null} +
+ ) : ( + <>
- - {editId ? ( -
- - {receipts.length > 0 && ( -
- {receipts.map((receipt) => { - const isImage = receipt.mimeType.startsWith("image/"); - const url = `/api/receipts/${receipt.id}`; - return ( -
- {isImage ? ( - // eslint-disable-next-line @next/next/no-img-element - {receipt.originalFilename} - ) : ( -
- -
- )} -
-

- {receipt.originalFilename} -

-

- {formatFileSize(receipt.sizeBytes)} -

-
- - -
- ); - })} -
- )} - 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" - /> -
- ) : ( -

- Save the expense first, then you can attach receipts. -

+ )} + +
- - + + + ) : ( + <> + + + )}
diff --git a/src/components/expenses/expense-receipt-indicator.tsx b/src/components/expenses/expense-receipt-indicator.tsx new file mode 100644 index 0000000..c717eda --- /dev/null +++ b/src/components/expenses/expense-receipt-indicator.tsx @@ -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( + null, + ); + + const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery( + { expenseId }, + { enabled: listOpen && receiptCount > 1 }, + ); + + if (receiptCount === 0) { + return ( + + ); + } + + 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 ( + <> + + + { + if (!open) setViewerReceipt(null); + }} + /> + + + + + Receipts ({receiptCount}) + +
+ {isLoading ? ( +
+ + Loading receipts… +
+ ) : ( + receipts.map((receipt) => ( + { + setListOpen(false); + setViewerReceipt(r); + }} + compact + /> + )) + )} +
+
+
+ + ); +} diff --git a/src/components/expenses/expense-receipt-item.tsx b/src/components/expenses/expense-receipt-item.tsx new file mode 100644 index 0000000..e485d75 --- /dev/null +++ b/src/components/expenses/expense-receipt-item.tsx @@ -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 ( + <> +
+ + +
+

+ {receipt.originalFilename} +

+

+ {formatReceiptSize(receipt.sizeBytes)} +

+
+ +
+ + + {!readOnly && ( + + )} +
+
+ + + + + Delete receipt? + + “{receipt.originalFilename}” will be permanently + removed. This cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + deleteReceipt.mutate({ id: receipt.id }); + }} + > + {deleteReceipt.isPending ? "Deleting…" : "Delete"} + + + + + + ); +} diff --git a/src/components/expenses/expense-receipts-panel.tsx b/src/components/expenses/expense-receipts-panel.tsx new file mode 100644 index 0000000..600defc --- /dev/null +++ b/src/components/expenses/expense-receipts-panel.tsx @@ -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( + 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 ( +
+ +
+ Save the expense first, then drag and drop receipts here. +
+
+ ); + } + + return ( +
+
+ + {uploadReceipt.isPending && ( + + + Uploading… + + )} +
+ + {isLoading ? ( +
+ + Loading receipts… +
+ ) : receipts.length === 0 ? ( +
+ {readOnly + ? "No receipts attached." + : "No receipts yet. Drop images or PDFs below."} +
+ ) : ( +
+ {receipts.map((receipt) => ( + + ))} +
+ )} + + {!readOnly && ( + 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" + /> + )} + + { + if (!open) setViewerReceipt(null); + }} + /> +
+ ); +} diff --git a/src/components/expenses/receipt-utils.ts b/src/components/expenses/receipt-utils.ts new file mode 100644 index 0000000..5b2908e --- /dev/null +++ b/src/components/expenses/receipt-utils.ts @@ -0,0 +1,44 @@ +export const RECEIPT_ACCEPT: Record = { + "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 { + return new Promise((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); + }); +} diff --git a/src/components/expenses/receipt-viewer-dialog.tsx b/src/components/expenses/receipt-viewer-dialog.tsx new file mode 100644 index 0000000..d87a00f --- /dev/null +++ b/src/components/expenses/receipt-viewer-dialog.tsx @@ -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 ( + + + + + {receipt.originalFilename} + + + +
+ {isImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {receipt.originalFilename} + ) : isPdf ? ( +