Compare commits

..
2 Commits
Author SHA1 Message Date
soconnorandCursor c267b3e1c8 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>
2026-06-27 00:47:59 -04:00
soconnor 5e6e51337a enable react compiler 2026-06-27 00:05:42 -04:00
18 changed files with 1402 additions and 279 deletions
+13 -2
View File
@@ -130,8 +130,18 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# =============================================================================
# Receipt storage — S3-compatible (optional)
# =============================================================================
# When unset, receipt files are stored locally in .data/receipts/ (dev-friendly).
# Works with AWS S3, MinIO, Cloudflare R2, etc.
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
# .data/receipts/ (dev-friendly). Works with AWS S3, MinIO, Cloudflare R2, etc.
#
# S3_ENDPOINT — who can reach MinIO?
# • Host dev (bun dev + docker-compose.dev.yml MinIO on the host): localhost:9000
# • App in Docker (docker-compose.yml): http://minio:9000 (Compose service name)
# • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+minio).
# - Application + separate MinIO: ENOTFOUND minio → set S3_ENDPOINT to
# SERVICE_URL_MINIO_9000 (public domain) OR http://minio-<resource-uuid>:9000
# with Connect to Predefined Network on both resources. Never bare "minio".
# - NEVER use localhost in production — inside the app container that is the app, not MinIO.
#
# Local dev with docker-compose.dev.yml MinIO (host `bun dev`):
S3_ENDPOINT=http://localhost:9000
@@ -139,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.
-2
View File
@@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile:
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
# - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=1 \
NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \
+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`.
### 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 |
+63
View File
@@ -0,0 +1,63 @@
# MinIO-only stack for Coolify when Beevoice runs as a separate Application resource.
#
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml
#
# ── Fix ENOTFOUND minio on a Beevoice Application (pick ONE) ─────────────────
#
# A) Public MinIO URL (most reliable — no shared Docker network required)
# 1. Redeploy this stack (includes SERVICE_FQDN_MINIO_9000 below).
# 2. MinIO resource → assign a domain for port 9000 (e.g. s3.example.com).
# 3. Open this resource's Environment tab → copy SERVICE_URL_MINIO_9000
# (e.g. https://s3.example.com).
# 4. Beevoice Application → S3_ENDPOINT=<that URL> → redeploy Beevoice.
#
# B) Internal Docker DNS (same Coolify destination network)
# 1. MinIO resource → Advanced → enable "Connect to Predefined Network" → redeploy.
# 2. Beevoice Application → same destination → enable "Connect to Predefined Network"
# → redeploy.
# 3. Beevoice → S3_ENDPOINT=http://minio-<MINIO_RESOURCE_UUID>:9000
# (UUID is in the MinIO resource URL / COOLIFY_RESOURCE_UUID — NOT bare "minio").
#
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+minio).
# See docs/COOLIFY.md.
services:
minio:
image: minio/minio:latest
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
SERVICE_FQDN_MINIO_9000:
SERVICE_FQDN_MINIO_9001:
volumes:
- beenvoice_minio_data:/data
command: server /data --console-address ":9001"
expose:
- "9000"
- "9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
entrypoint: ["/bin/sh", "-c"]
command:
- >-
mc alias set local http://minio:9000
"$${MINIO_ROOT_USER:-minioadmin}"
"$${MINIO_ROOT_PASSWORD:-minioadmin}" &&
mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing
restart: "no"
volumes:
beenvoice_minio_data:
+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:
+9 -7
View File
@@ -19,7 +19,7 @@ services:
# S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001.
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
image: minio/minio:latest
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
@@ -37,7 +37,7 @@ services:
restart: unless-stopped
minio-init:
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
@@ -45,11 +45,13 @@ services:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb local/$$S3_BUCKET --ignore-existing
"
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:
+13 -7
View File
@@ -1,5 +1,9 @@
# Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml
#
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
# or this file. S3_ENDPOINT=http://minio:9000 works only inside a single stack.
# Application + separate MinIO → docs/COOLIFY.md.
#
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
# the existing local image and will NOT include new code. Use:
# ./scripts/docker-deploy.sh
@@ -65,7 +69,7 @@ services:
# S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose).
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
image: minio/minio:latest
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
@@ -83,7 +87,7 @@ services:
restart: unless-stopped
minio-init:
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
@@ -91,11 +95,13 @@ services:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb local/$$S3_BUCKET --ignore-existing
"
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:
+135
View File
@@ -0,0 +1,135 @@
# Coolify deployment — Beevoice + MinIO
Beevoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. MinIO is the usual choice on self-hosted Coolify.
## Why `getaddrinfo ENOTFOUND minio` happens
Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://minio:9000` work? |
|-------|-------------------------------|
| Single Compose stack (app + minio together) | Yes — Compose service name `minio` |
| Beevoice **Application** + MinIO **separate Compose** | **No** — each resource has its own network by default |
| Application + MinIO with shared destination network + correct hostname | Yes — hostname is usually **`minio-<resource-uuid>`**, not bare `minio` |
| Application + MinIO via **public domain** (`SERVICE_URL_MINIO_9000`) | Yes — no Docker DNS needed |
Setting `S3_ENDPOINT=http://minio:9000` on a standalone Beevoice Application fails because the app container is not on the MinIO stack's network. Node returns `ENOTFOUND minio`.
Also avoid `http://localhost:9000` inside the app container — that points at the app itself, not MinIO.
---
## Quick fix — keep Beevoice as Application + separate MinIO compose
Use this if you are **not** migrating to a single Compose stack today.
### Path A — public MinIO URL (recommended, works without shared Docker network)
This is the most reliable fix when Beevoice is a Coolify **Application** (Dockerfile) and MinIO is a separate Compose resource.
1. **Update the MinIO stack** to the latest `docker-compose.coolify-minio.yml` from this repo (includes `SERVICE_FQDN_MINIO_9000`) and **redeploy** the MinIO resource.
2. In the **MinIO Compose resource** → assign a domain for **port 9000** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
3. Open the MinIO resource **Environment** tab and copy **`SERVICE_URL_MINIO_9000`** (e.g. `https://s3.yourdomain.com`).
4. On the **Beevoice Application** → Environment:
```env
S3_ENDPOINT=https://s3.yourdomain.com
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<same as MINIO_ROOT_USER>
S3_SECRET_KEY=<same as MINIO_ROOT_PASSWORD>
S3_REGION=us-east-1
```
5. **Redeploy Beevoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for MinIO behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets.
### Path B — internal Docker DNS (same destination, no public MinIO domain)
Use when you want MinIO API traffic to stay on the Docker network.
1. Put Beevoice Application and MinIO Compose in the **same Coolify project** and **same destination** (server/network).
2. **MinIO Compose resource****Advanced** → enable **Connect to Predefined Network****redeploy MinIO**.
3. **Beevoice Application****Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy Beevoice**.
4. Find the MinIO resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the MinIO container).
5. Set on Beevoice Application:
```env
S3_ENDPOINT=http://minio-<MINIO_RESOURCE_UUID>:9000
```
Example: resource UUID `k8w2o0g4s0g8``S3_ENDPOINT=http://minio-k8w2o0g4s0g8:9000`.
**Do not use bare `minio`** unless you verified it resolves from inside the Beevoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://minio:9000/minio/health/live` fails, use the `minio-<uuid>` form or Path A).
6. Match credentials and bucket:
```env
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<MINIO_ROOT_USER>
S3_SECRET_KEY=<MINIO_ROOT_PASSWORD>
S3_REGION=us-east-1
```
---
## Recommended long-term — one Compose stack
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + MinIO + minio-init). This is the lowest-friction production layout on Coolify.
1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `MINIO_ROOT_*`, etc.
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://minio:9000` on the shared network.
6. Redeploy.
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/MinIO.
### Migrating from Application + external Postgres + MinIO
| Current | Action |
|---------|--------|
| Beevoice Application | Remove after Compose stack is live |
| Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file |
| MinIO compose | Remove after data migrated or re-point receipts (new bucket) |
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
---
## Compose file reference
| File | Purpose |
|------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-minio.yml`](../docker-compose.coolify-minio.yml) | MinIO + bucket init only; pair with Beevoice Application (Path A or B above) |
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
---
## Checklist (Application + separate MinIO)
- [ ] MinIO stack redeployed with current `docker-compose.coolify-minio.yml`
- [ ] **Path A:** domain on port 9000 + `S3_ENDPOINT` = `SERVICE_URL_MINIO_9000`
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://minio-<uuid>:9000`
- [ ] `S3_ENDPOINT` is **not** `http://minio:9000`, **not** `localhost`
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`
- [ ] `S3_BUCKET` exists (`minio-init` creates `beenvoice-receipts` by default)
- [ ] Redeployed Beevoice after env or network changes
## Verify from the Beevoice container
```bash
# Shell into Beevoice app container on the Coolify server
docker exec -it <beenvoice-container> sh
# Path A — public URL (include scheme; path is /minio/health/live on API port)
wget -qO- "https://s3.yourdomain.com/minio/health/live" || curl -sf "https://s3.yourdomain.com/minio/health/live"
# Path B — internal host from S3_ENDPOINT (no scheme/port in HOST)
wget -qO- "http://minio-<uuid>:9000/minio/health/live" || curl -sf "http://minio-<uuid>:9000/minio/health/live"
```
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `minio` in production.
+1
View File
@@ -8,6 +8,7 @@
|----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
| [../README.md](../README.md) | Install, scripts, deployment |
| [COOLIFY.md](./COOLIFY.md) | Coolify + MinIO networking (`ENOTFOUND minio`) |
## UI & product guides
+234 -170
View File
@@ -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<ExpenseDialogMode>("create");
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(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<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 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 (
<DashboardPage>
@@ -299,9 +296,9 @@ export default function ExpensesPage() {
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Count
With receipts
</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
<p className="mt-1 text-2xl font-bold">{withReceipts}</p>
</CardContent>
</Card>
</div>
@@ -330,11 +327,27 @@ export default function ExpensesPage() {
}
/>
) : (
<>
<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">
<span>Expense</span>
<span className="text-center">Receipts</span>
<span className="text-right">Amount</span>
<span className="w-[108px]" />
</div>
<div className="divide-y">
{expenses.map((expense) => (
<div
key={expense.id}
className="flex items-start justify-between gap-3 p-4"
role="button"
tabIndex={0}
onClick={() => 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"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -362,12 +375,6 @@ export default function ExpensesPage() {
{expense.category}
</Badge>
)}
{(expense.receipts?.length ?? 0) > 0 && (
<Badge variant="outline" className="text-xs">
<Paperclip className="mr-1 h-3 w-3" />
{expense.receipts?.length}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", {
@@ -384,15 +391,44 @@ export default function ExpensesPage() {
</p>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<p className="font-semibold">
<div
className="flex items-center sm:justify-center"
onClick={(e) => e.stopPropagation()}
>
<span className="text-muted-foreground mr-2 text-xs sm:hidden">
Receipts
</span>
<ExpenseReceiptIndicator
expenseId={expense.id}
receiptCount={expense.receiptCount}
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>
<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>
@@ -401,13 +437,16 @@ export default function ExpensesPage() {
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>
</>
)}
</CardContent>
</Card>
@@ -418,15 +457,95 @@ export default function ExpensesPage() {
setOpen(next);
if (!next) {
setEditId(null);
setDialogMode("create");
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>
<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>
<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">
<Label>Description *</Label>
<Input
@@ -584,82 +703,25 @@ export default function ExpensesPage() {
placeholder="Additional details…"
/>
</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" />
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
</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>
<DialogFooter className="gap-2 sm:gap-0">
{isViewMode ? (
<>
<Button variant="outline" onClick={closeDialog}>
Close
</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 onClick={() => setDialogMode("edit")}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</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>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
<>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button
@@ -668,10 +730,12 @@ export default function ExpensesPage() {
>
{create.isPending || update.isPending
? "Saving…"
: editId
: isEditMode
? "Update"
: "Add Expense"}
: "Save & add receipts"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</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_SECRET_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_FORCE_PATH_STYLE: optionalEnvBoolean(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -87,6 +88,7 @@ export const env = createEnv({
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
S3_REGION: process.env.S3_REGION,
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
+62 -5
View File
@@ -21,6 +21,56 @@ type S3Module = typeof import("@aws-sdk/client-s3");
let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
let s3DnsHintLogged = false;
let s3BareMinioHintLogged = false;
function shouldForcePathStyle(): boolean {
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
if (override === "true" || override === "1") return true;
if (override === "false" || override === "0") return false;
return Boolean(process.env.S3_ENDPOINT);
}
function logBareMinioEndpointHint(): void {
if (s3BareMinioHintLogged || process.env.NODE_ENV !== "production") return;
const endpoint = process.env.S3_ENDPOINT;
if (!endpoint) return;
try {
const { hostname } = new URL(endpoint);
if (hostname !== "minio") return;
s3BareMinioHintLogged = true;
console.warn(
"[object-storage] S3_ENDPOINT hostname is bare 'minio'. " +
"That only resolves inside a single Docker Compose stack. " +
"Coolify Application + separate MinIO compose: set S3_ENDPOINT to " +
"SERVICE_URL_MINIO_9000 (public domain) or http://minio-<resource-uuid>:9000. " +
"See docs/COOLIFY.md.",
);
} catch {
// Invalid URL — env validation or S3 client will surface it.
}
}
function logS3DnsHint(error: unknown): void {
if (s3DnsHintLogged) return;
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
s3DnsHintLogged = true;
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
console.error(
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
"Separate Coolify stacks cannot resolve bare 'minio' — use the internal hostname from the MinIO resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
);
}
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
logS3DnsHint(error);
throw error;
}
}
async function getS3() {
if (!s3ModulePromise) {
@@ -28,6 +78,7 @@ async function getS3() {
}
const mod = await s3ModulePromise;
if (!s3Client) {
logBareMinioEndpointHint();
s3Client = new mod.S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT,
@@ -35,8 +86,8 @@ async function getS3() {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
// Required for MinIO and most S3-compatible endpoints.
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
// Required for MinIO and most S3-compatible endpoints (including HTTPS proxies).
forcePathStyle: shouldForcePathStyle(),
});
}
return { client: s3Client, ...mod };
@@ -53,13 +104,15 @@ export async function putObject(
): Promise<void> {
if (isS3Configured()) {
const { client, PutObjectCommand } = await getS3();
await client.send(
await withS3Diagnostics(() =>
client.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
}),
),
);
return;
}
@@ -72,11 +125,13 @@ export async function putObject(
export async function getObject(key: string): Promise<Buffer> {
if (isS3Configured()) {
const { client, GetObjectCommand } = await getS3();
const response = await client.send(
const response = await withS3Diagnostics(() =>
client.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) {
@@ -91,11 +146,13 @@ export async function getObject(key: string): Promise<Buffer> {
export async function deleteObject(key: string): Promise<void> {
if (isS3Configured()) {
const { client, DeleteObjectCommand } = await getS3();
await client.send(
await withS3Diagnostics(() =>
client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
return;
}
+54 -3
View File
@@ -1,5 +1,5 @@
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 {
expenses,
@@ -132,16 +132,67 @@ export const expensesRouter = createTRPCRouter({
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),
with: {
client: true,
business: true,
invoice: true,
receipts: true,
},
orderBy: [desc(expenses.date)],
});
const expenseIds = rows.map((e) => e.id);
if (expenseIds.length === 0) return [];
const receiptMeta = await ctx.db
.select({
expenseId: expenseReceipts.expenseId,
id: expenseReceipts.id,
mimeType: expenseReceipts.mimeType,
originalFilename: expenseReceipts.originalFilename,
createdAt: expenseReceipts.createdAt,
})
.from(expenseReceipts)
.where(inArray(expenseReceipts.expenseId, expenseIds))
.orderBy(desc(expenseReceipts.createdAt));
const receiptStats = new Map<
string,
{
receiptCount: number;
receiptPreview: {
id: string;
mimeType: string;
originalFilename: string;
} | null;
}
>();
for (const receipt of receiptMeta) {
const existing = receiptStats.get(receipt.expenseId);
if (existing) {
existing.receiptCount += 1;
} else {
receiptStats.set(receipt.expenseId, {
receiptCount: 1,
receiptPreview: {
id: receipt.id,
mimeType: receipt.mimeType,
originalFilename: receipt.originalFilename,
},
});
}
}
return rows.map((expense) => {
const stats = receiptStats.get(expense.id);
return {
...expense,
receiptCount: stats?.receiptCount ?? 0,
receiptPreview: stats?.receiptPreview ?? null,
};
});
}),
getById: protectedProcedure