add receipts support
This commit is contained in:
+23
-2
@@ -4,9 +4,10 @@
|
|||||||
#
|
#
|
||||||
# Quick start (local dev):
|
# Quick start (local dev):
|
||||||
# cp .env.example .env.local
|
# cp .env.example .env.local
|
||||||
# docker compose -f docker-compose.dev.yml up -d
|
# docker compose -f docker-compose.dev.yml up -d # Postgres + MinIO
|
||||||
# bun run db:push # or: bun run db:migrate
|
# bun run db:push # or: bun run db:migrate
|
||||||
# bun run dev
|
# bun run dev
|
||||||
|
# MinIO console: http://localhost:9001 (minioadmin / minioadmin)
|
||||||
#
|
#
|
||||||
# Quick start (Docker app + Postgres):
|
# Quick start (Docker app + Postgres):
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
@@ -63,8 +64,10 @@ NODE_ENV=development
|
|||||||
# Set true when connecting to local Postgres without SSL (default for compose).
|
# Set true when connecting to local Postgres without SSL (default for compose).
|
||||||
DB_DISABLE_SSL=true
|
DB_DISABLE_SSL=true
|
||||||
|
|
||||||
# Dev-only: host port for `docker compose -f docker-compose.dev.yml` Postgres.
|
# Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
MINIO_API_PORT=9000
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
|
||||||
# Optional: if Next dev picks another port, you do not need to change URLs for
|
# Optional: if Next dev picks another port, you do not need to change URLs for
|
||||||
# sign-in — the auth client uses window.location.origin in the browser.
|
# sign-in — the auth client uses window.location.origin in the browser.
|
||||||
@@ -124,6 +127,24 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
|||||||
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
|
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
|
||||||
# CRON_SECRET=
|
# CRON_SECRET=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Receipt storage — S3-compatible (optional)
|
||||||
|
# =============================================================================
|
||||||
|
# When unset, receipt files are stored locally in .data/receipts/ (dev-friendly).
|
||||||
|
# Works with AWS S3, MinIO, Cloudflare R2, etc.
|
||||||
|
#
|
||||||
|
# Local dev with docker-compose.dev.yml MinIO (host `bun dev`):
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_BUCKET=beenvoice-receipts
|
||||||
|
S3_ACCESS_KEY=minioadmin
|
||||||
|
S3_SECRET_KEY=minioadmin
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SSO — Authentik OIDC (optional)
|
# SSO — Authentik OIDC (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ next-env.d.ts
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.data/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "beenvoice",
|
"name": "beenvoice",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1075.0",
|
||||||
"@better-auth/expo": "^1.6.19",
|
"@better-auth/expo": "^1.6.19",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
@@ -102,6 +103,60 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||||
|
|
||||||
|
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||||
|
|
||||||
|
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||||
|
|
||||||
|
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1075.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-node": "^3.972.58", "@aws-sdk/middleware-flexible-checksums": "^3.974.33", "@aws-sdk/middleware-sdk-s3": "^3.972.54", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/core": ["@aws-sdk/core@3.974.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.31", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-login": "^3.972.55", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.58", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-ini": "^3.972.56", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/token-providers": "3.1074.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.33", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.8", "tslib": "^2.6.2" } }, "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g=="],
|
||||||
|
|
||||||
|
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.23", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.35", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1074.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="],
|
||||||
|
|
||||||
|
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.31", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ=="],
|
||||||
|
|
||||||
|
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
@@ -488,6 +543,24 @@
|
|||||||
|
|
||||||
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||||
|
|
||||||
|
"@smithy/core": ["@smithy/core@3.26.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g=="],
|
||||||
|
|
||||||
|
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug=="],
|
||||||
|
|
||||||
|
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg=="],
|
||||||
|
|
||||||
|
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
|
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ=="],
|
||||||
|
|
||||||
|
"@smithy/signature-v4": ["@smithy/signature-v4@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA=="],
|
||||||
|
|
||||||
|
"@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="],
|
||||||
|
|
||||||
|
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
|
||||||
|
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
@@ -766,6 +839,8 @@
|
|||||||
|
|
||||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||||
|
|
||||||
|
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|||||||
@@ -17,5 +17,41 @@ services:
|
|||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001.
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
volumes:
|
||||||
|
- beenvoice_dev_minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
|
||||||
|
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 "
|
||||||
|
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||||
|
mc mb local/$$S3_BUCKET --ignore-existing
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
beenvoice_dev_pg_data:
|
beenvoice_dev_pg_data:
|
||||||
|
beenvoice_dev_minio_data:
|
||||||
|
|||||||
+46
-1
@@ -1,4 +1,4 @@
|
|||||||
# Production stack (app + Postgres). Local dev Postgres-only: docker-compose.dev.yml
|
# Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml
|
||||||
#
|
#
|
||||||
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
|
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
|
||||||
# the existing local image and will NOT include new code. Use:
|
# the existing local image and will NOT include new code. Use:
|
||||||
@@ -31,11 +31,20 @@ services:
|
|||||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
|
S3_ENDPOINT: http://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}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -54,5 +63,41 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose).
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
volumes:
|
||||||
|
- beenvoice_minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
|
||||||
|
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 "
|
||||||
|
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||||
|
mc mb local/$$S3_BUCKET --ignore-existing
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
beenvoice_pg_data:
|
beenvoice_pg_data:
|
||||||
|
beenvoice_minio_data:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_audit_log" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"actorUserId" varchar(255) NOT NULL,
|
||||||
|
"action" varchar(100) NOT NULL,
|
||||||
|
"targetType" varchar(50) NOT NULL,
|
||||||
|
"targetId" varchar(255),
|
||||||
|
"metadata" jsonb,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_audit_log"
|
||||||
|
ADD CONSTRAINT "beenvoice_audit_log_actorUserId_beenvoice_user_id_fk"
|
||||||
|
FOREIGN KEY ("actorUserId") REFERENCES "public"."beenvoice_user"("id")
|
||||||
|
ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx" ON "beenvoice_audit_log" USING btree ("actorUserId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "beenvoice_audit_log" USING btree ("action");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx" ON "beenvoice_audit_log" USING btree ("createdAt");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS "expense_business_id_idx" ON "beenvoice_expense" USING btree ("businessId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_expense_receipt" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"expenseId" varchar(255) NOT NULL,
|
||||||
|
"storageKey" varchar(500) NOT NULL,
|
||||||
|
"originalFilename" varchar(255) NOT NULL,
|
||||||
|
"mimeType" varchar(100) NOT NULL,
|
||||||
|
"sizeBytes" integer NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense_receipt"
|
||||||
|
ADD CONSTRAINT "beenvoice_expense_receipt_expenseId_beenvoice_expense_id_fk"
|
||||||
|
FOREIGN KEY ("expenseId") REFERENCES "public"."beenvoice_expense"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "expense_receipt_expense_id_idx" ON "beenvoice_expense_receipt" USING btree ("expenseId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_expense" e
|
||||||
|
SET "businessId" = i."businessId"
|
||||||
|
FROM "beenvoice_invoice" i
|
||||||
|
WHERE e."invoiceId" = i.id
|
||||||
|
AND e."businessId" IS NULL
|
||||||
|
AND i."businessId" IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_expense" e
|
||||||
|
SET "businessId" = sub.business_id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
e2.id AS expense_id,
|
||||||
|
(
|
||||||
|
SELECT b2.id
|
||||||
|
FROM "beenvoice_business" b2
|
||||||
|
WHERE b2."createdById" = e2."createdById"
|
||||||
|
ORDER BY b2."isDefault" DESC, b2."createdAt" DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS business_id
|
||||||
|
FROM "beenvoice_expense" e2
|
||||||
|
WHERE e2."businessId" IS NULL
|
||||||
|
) sub
|
||||||
|
WHERE e.id = sub.expense_id
|
||||||
|
AND sub.business_id IS NOT NULL;
|
||||||
@@ -134,6 +134,34 @@
|
|||||||
"when": 1781700000000,
|
"when": 1781700000000,
|
||||||
"tag": "0018_user_onboarding",
|
"tag": "0018_user_onboarding",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781800000000,
|
||||||
|
"tag": "0019_pdf_font_family",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781900000000,
|
||||||
|
"tag": "0020_pdf_numeric_font_family",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782000000000,
|
||||||
|
"tag": "0021_audit_log",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782100000000,
|
||||||
|
"tag": "0022_expense_business_receipts",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1075.0",
|
||||||
"@better-auth/expo": "^1.6.19",
|
"@better-auth/expo": "^1.6.19",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
|
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
|
||||||
# Rebuilds the app image from the current working tree, then starts/restarts services.
|
# Rebuilds the app image from the current working tree, then starts/restarts services
|
||||||
|
# (app, db, minio, minio-init). Receipt storage uses in-stack MinIO unless S3_* are
|
||||||
|
# overridden in .env. MinIO API/console: localhost:${MINIO_API_PORT:-9000} / :9001.
|
||||||
#
|
#
|
||||||
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
|
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
|
||||||
# changes from `git pull`. Always pass --build or use this script after pulling.
|
# changes from `git pull`. Always pass --build or use this script after pulling.
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
import { Resend } from "resend";
|
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||||
import { env } from "~/env";
|
|
||||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
|
||||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -17,7 +12,6 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(email)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -26,13 +20,11 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email.toLowerCase()),
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always return success to prevent email enumeration attacks
|
|
||||||
// Don't reveal whether the user exists or not
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -44,62 +36,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate reset token
|
await sendPasswordResetForUser(user.id);
|
||||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
|
||||||
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
||||||
|
|
||||||
// Update user with reset token
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
resetToken,
|
|
||||||
resetTokenExpiry,
|
|
||||||
})
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
|
|
||||||
if (!env.RESEND_API_KEY) {
|
|
||||||
console.warn(
|
|
||||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
|
||||||
);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
"If an account with that email exists, password reset instructions have been sent.",
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send password reset email using Resend
|
|
||||||
try {
|
|
||||||
const resend = new Resend(env.RESEND_API_KEY);
|
|
||||||
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
|
|
||||||
|
|
||||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
|
||||||
userEmail: email,
|
|
||||||
userName: user.name ?? undefined,
|
|
||||||
resetToken,
|
|
||||||
resetUrl,
|
|
||||||
expiryHours: 24,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: `beenvoice <noreply@${fromDomain}>`,
|
|
||||||
to: email,
|
|
||||||
subject: emailTemplate.subject,
|
|
||||||
html: emailTemplate.html,
|
|
||||||
text: emailTemplate.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Password reset email sent to: ${email}`);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error("Failed to send password reset email:", emailError);
|
|
||||||
// Continue execution - don't fail the request if email fails
|
|
||||||
// This prevents revealing whether an account exists based on email delivery
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export async function GET(
|
|||||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
||||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||||
pdfAccentColor: settings?.pdfAccentColor,
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
|
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
|
||||||
|
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||||
|
| "sans"
|
||||||
|
| "serif"
|
||||||
|
| "mono"
|
||||||
|
| undefined,
|
||||||
pdfFooterText: settings?.pdfFooterText,
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
pdfShowLogo: settings?.pdfShowLogo,
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||||
|
import { getObject } from "~/lib/object-storage";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { expenseReceipts } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getOptionalServerSession(req.headers);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const receipt = await db.query.expenseReceipts.findFirst({
|
||||||
|
where: eq(expenseReceipts.id, id),
|
||||||
|
with: { expense: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!receipt || receipt.expense.createdById !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await getObject(receipt.storageKey);
|
||||||
|
return new NextResponse(new Uint8Array(body), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": receipt.mimeType,
|
||||||
|
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Shield } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
Building2,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
ScrollText,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDeferredValue, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
|
import { dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -9,6 +41,15 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -18,74 +59,374 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export function AdministrationContent() {
|
const PAGE_SIZE = 25;
|
||||||
const {
|
|
||||||
data: accounts = [],
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
refetch,
|
"user.profile_updated": "Profile updated",
|
||||||
error,
|
"user.role_updated": "Role updated",
|
||||||
} = api.settings.listAccounts.useQuery();
|
"user.password_reset_sent": "Password reset sent",
|
||||||
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
"platform.pdf_settings_updated": "PDF settings updated",
|
||||||
onSuccess: () => {
|
};
|
||||||
toast.success("Account role updated");
|
|
||||||
void refetch();
|
function formatAction(action: string) {
|
||||||
},
|
return ACTION_LABELS[action] ?? action;
|
||||||
onError: (mutationError: { message: string }) => {
|
}
|
||||||
toast.error(`Failed to update role: ${mutationError.message}`);
|
|
||||||
},
|
function AdminOverview() {
|
||||||
});
|
const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle>Platform overview</CardTitle>
|
||||||
|
<CardDescription>Unable to load statistics.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: "Total users",
|
||||||
|
value: stats?.totalUsers ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Active (${stats?.activeUserWindowDays ?? 30}d)`,
|
||||||
|
value: stats?.activeUsers ?? 0,
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Administrators",
|
||||||
|
value: stats?.adminCount ?? 0,
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Invoices",
|
||||||
|
value: stats?.totalInvoices ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Businesses",
|
||||||
|
value: stats?.totalBusinesses ?? 0,
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clients",
|
||||||
|
value: stats?.totalClients ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Time entries",
|
||||||
|
value: stats?.totalTimeEntries ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="text-primary h-5 w-5" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
Administration
|
Platform overview
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Administrative access is required for this page.
|
Aggregate counts only — no customer data, credentials, or bulk PII.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading statistics…</p>
|
||||||
|
) : (
|
||||||
|
<div className={dashboardStatGridClass}>
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Card key={stat.label}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||||
|
<stat.icon className="h-3.5 w-3.5" />
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditUserState = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "user" | "admin";
|
||||||
|
};
|
||||||
|
|
||||||
|
function AdminUsers() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [editUser, setEditUser] = useState<EditUserState | null>(null);
|
||||||
|
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
|
const [resetUserName, setResetUserName] = useState("");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data, isLoading, error, isFetching } = api.admin.listUsers.useQuery({
|
||||||
|
search: deferredSearch || undefined,
|
||||||
|
offset,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = api.admin.updateUser.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("User updated");
|
||||||
|
setEditUser(null);
|
||||||
|
void utils.admin.listUsers.invalidate();
|
||||||
|
void utils.admin.listAuditLog.invalidate();
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error(mutationError.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendResetMutation = api.admin.sendPasswordReset.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.emailSent) {
|
||||||
|
toast.success("Password reset email sent");
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
"Reset token created, but email could not be sent. Check Resend configuration.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setResetUserId(null);
|
||||||
|
void utils.admin.listAuditLog.invalidate();
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error(mutationError.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>Administrative access is required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<>
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="text-primary h-5 w-5" />
|
<Users className="text-primary h-5 w-5" />
|
||||||
Accounts
|
Users
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage account access and roles without opening customer data.
|
Search accounts, edit profiles, and manage access.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
{accounts.map((account) => (
|
<div className="relative max-w-md">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search by name or email…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading users…</p>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users className="h-6 w-6" />}
|
||||||
|
title="No users found"
|
||||||
|
description={
|
||||||
|
deferredSearch
|
||||||
|
? "Try a different search term."
|
||||||
|
: "No accounts have been created yet."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-border divide-y border">
|
||||||
|
{users.map((user) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={user.id}
|
||||||
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
|
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{account.name}</p>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{user.name}</p>
|
||||||
|
<Badge
|
||||||
|
variant={user.role === "admin" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
{user.emailVerified ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
{account.email}
|
{user.email}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Created {new Date(account.createdAt).toLocaleDateString()}
|
Joined{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<div className="flex flex-shrink-0 gap-2">
|
||||||
value={account.role}
|
<Button
|
||||||
onValueChange={(role) =>
|
variant="outline"
|
||||||
updateAccountRoleMutation.mutate({
|
size="sm"
|
||||||
userId: account.id,
|
onClick={() =>
|
||||||
role: role as "user" | "admin",
|
setEditUser({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role as "user" | "admin",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full sm:w-36">
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setResetUserId(user.id);
|
||||||
|
setResetUserName(user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > PAGE_SIZE ? (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Page {currentPage} of {totalPages} · {total} users
|
||||||
|
{isFetching ? " · Updating…" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editUser != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit user</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editUser ? (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateUserMutation.mutate({
|
||||||
|
userId: editUser.id,
|
||||||
|
name: editUser.name,
|
||||||
|
email: editUser.email,
|
||||||
|
role: editUser.role,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-user-name"
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, name: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-user-email"
|
||||||
|
type="email"
|
||||||
|
value={editUser.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, email: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-user-role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={editUser.role}
|
||||||
|
onValueChange={(role) =>
|
||||||
|
setEditUser((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, role: role as "user" | "admin" }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="edit-user-role">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -94,8 +435,187 @@ export function AdministrationContent() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditUser(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateUserMutation.isPending}>
|
||||||
|
{updateUserMutation.isPending ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={resetUserId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setResetUserId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Send password reset?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
A password reset email will be sent to{" "}
|
||||||
|
<span className="font-medium">{resetUserName}</span>. The link
|
||||||
|
expires in 24 hours.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={sendResetMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (resetUserId) {
|
||||||
|
sendResetMutation.mutate({ userId: resetUserId });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sendResetMutation.isPending ? "Sending…" : "Send reset email"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminAuditLog() {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const { data, isLoading, error, isFetching } = api.admin.listAuditLog.useQuery(
|
||||||
|
{
|
||||||
|
offset,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit log</CardTitle>
|
||||||
|
<CardDescription>Administrative access is required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ScrollText className="text-primary h-5 w-5" />
|
||||||
|
Audit log
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Recent administrative actions across the platform.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading audit log…</p>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ScrollText className="h-6 w-6" />}
|
||||||
|
title="No audit events yet"
|
||||||
|
description="Administrative actions will appear here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-border divide-y border">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div key={entry.id} className="space-y-1 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatAction(entry.action)}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{entry.targetType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{entry.actor?.name ?? "Unknown admin"} ·{" "}
|
||||||
|
{new Date(entry.createdAt).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
{entry.targetId ? ` · target ${entry.targetId.slice(0, 8)}…` : ""}
|
||||||
|
</p>
|
||||||
|
{entry.metadata &&
|
||||||
|
Object.keys(entry.metadata).length > 0 ? (
|
||||||
|
<p className="text-muted-foreground font-mono text-xs break-all">
|
||||||
|
{JSON.stringify(entry.metadata)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > PAGE_SIZE ? (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Page {currentPage} of {totalPages} · {total} events
|
||||||
|
{isFetching ? " · Updating…" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AdministrationContent() {
|
||||||
|
return (
|
||||||
|
<PageTabs defaultValue="overview">
|
||||||
|
<PageTabsList>
|
||||||
|
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="users">Users</PageTabsTrigger>
|
||||||
|
<PageTabsTrigger value="audit">Audit log</PageTabsTrigger>
|
||||||
|
</PageTabsList>
|
||||||
|
|
||||||
|
<PageTabsContent value="overview">
|
||||||
|
<AdminOverview />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent value="users">
|
||||||
|
<AdminUsers />
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
|
<PageTabsContent value="audit">
|
||||||
|
<AdminAuditLog />
|
||||||
|
</PageTabsContent>
|
||||||
|
</PageTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default async function AdministrationPage() {
|
|||||||
<DashboardPage>
|
<DashboardPage>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
description="Manage account access and platform administration"
|
description="Platform statistics, user management, and audit logging"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
@@ -28,8 +28,17 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import { FileUpload } from "~/components/forms/file-upload";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Receipt,
|
||||||
|
FileText,
|
||||||
|
Paperclip,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
@@ -44,6 +53,7 @@ interface ExpenseFormData {
|
|||||||
taxDeductible: boolean;
|
taxDeductible: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
businessId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm: ExpenseFormData = {
|
const defaultForm: ExpenseFormData = {
|
||||||
@@ -57,24 +67,49 @@ const defaultForm: ExpenseFormData = {
|
|||||||
taxDeductible: false,
|
taxDeductible: false,
|
||||||
notes: "",
|
notes: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExpensesPage() {
|
export default function ExpensesPage() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [businessFilter, setBusinessFilter] = useState("all");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||||
|
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery(
|
||||||
|
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||||
|
);
|
||||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||||
|
const { data: receipts = [] } = api.expenses.listReceipts.useQuery(
|
||||||
|
{ expenseId: editId! },
|
||||||
|
{ enabled: !!editId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultBusinessId = useMemo(
|
||||||
|
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
|
||||||
|
[businesses],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || editId || !defaultBusinessId || form.businessId) return;
|
||||||
|
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
|
||||||
|
}, [open, editId, defaultBusinessId, form.businessId]);
|
||||||
|
|
||||||
const create = api.expenses.create.useMutation({
|
const create = api.expenses.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: (expense) => {
|
||||||
|
if (!expense) return;
|
||||||
toast.success("Expense added");
|
toast.success("Expense added");
|
||||||
void utils.expenses.getAll.invalidate();
|
void utils.expenses.getAll.invalidate();
|
||||||
setOpen(false);
|
setEditId(expense.id);
|
||||||
setForm(defaultForm);
|
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -96,10 +131,30 @@ export default function ExpensesPage() {
|
|||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
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 handleOpen = () => {
|
const handleOpen = () => {
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
setForm(defaultForm);
|
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||||
@@ -115,6 +170,7 @@ export default function ExpensesPage() {
|
|||||||
taxDeductible: expense.taxDeductible ?? false,
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
notes: expense.notes ?? "",
|
notes: expense.notes ?? "",
|
||||||
clientId: expense.clientId ?? "",
|
clientId: expense.clientId ?? "",
|
||||||
|
businessId: expense.businessId ?? defaultBusinessId,
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
@@ -130,6 +186,7 @@ export default function ExpensesPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
clientId: form.clientId || undefined,
|
clientId: form.clientId || undefined,
|
||||||
|
businessId: form.businessId || undefined,
|
||||||
category: form.category || undefined,
|
category: form.category || undefined,
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
taxDeductible: form.taxDeductible,
|
taxDeductible: form.taxDeductible,
|
||||||
@@ -138,6 +195,36 @@ export default function ExpensesPage() {
|
|||||||
else create.mutate(payload);
|
else create.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReceiptFiles = async (files: File[]) => {
|
||||||
|
if (!editId) {
|
||||||
|
toast.error("Save the expense before uploading receipts");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
const data = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(",")[1];
|
||||||
|
if (!base64) {
|
||||||
|
reject(new Error("Failed to read file"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: editId,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||||
const billableTotal = expenses
|
const billableTotal = expenses
|
||||||
.filter((e) => e.billable)
|
.filter((e) => e.billable)
|
||||||
@@ -161,6 +248,23 @@ export default function ExpensesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DashboardPageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<Label className="text-sm">Business</Label>
|
||||||
|
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder="All businesses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All businesses</SelectItem>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={dashboardStatGridClass}>
|
<div className={dashboardStatGridClass}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -202,7 +306,6 @@ export default function ExpensesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expenses list */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -259,6 +362,12 @@ export default function ExpensesPage() {
|
|||||||
{expense.category}
|
{expense.category}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
@@ -266,6 +375,7 @@ export default function ExpensesPage() {
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(expense.date))}
|
}).format(new Date(expense.date))}
|
||||||
|
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
</p>
|
</p>
|
||||||
{expense.notes && (
|
{expense.notes && (
|
||||||
@@ -302,8 +412,16 @@ export default function ExpensesPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add/Edit dialog */}
|
<Dialog
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) {
|
||||||
|
setEditId(null);
|
||||||
|
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-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||||
@@ -381,6 +499,31 @@ export default function ExpensesPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Business</Label>
|
||||||
|
<Select
|
||||||
|
value={form.businessId || "none"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((p) => ({
|
||||||
|
...p,
|
||||||
|
businessId: v === "none" ? "" : v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select business" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Default business</SelectItem>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
{b.isDefault ? " (default)" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Client (optional)</Label>
|
<Label>Client (optional)</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -441,6 +584,79 @@ export default function ExpensesPage() {
|
|||||||
placeholder="Additional details…"
|
placeholder="Additional details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editId ? (
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<Label>Receipts</Label>
|
||||||
|
{receipts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{receipts.map((receipt) => {
|
||||||
|
const isImage = receipt.mimeType.startsWith("image/");
|
||||||
|
const url = `/api/receipts/${receipt.id}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={receipt.id}
|
||||||
|
className="flex items-center gap-3 rounded-md border p-2"
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={receipt.originalFilename}
|
||||||
|
className="h-12 w-12 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded">
|
||||||
|
<FileText className="text-muted-foreground h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatFileSize(receipt.sizeBytes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
deleteReceipt.mutate({ id: receipt.id })
|
||||||
|
}
|
||||||
|
disabled={deleteReceipt.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FileUpload
|
||||||
|
onFilesSelected={(files) => void handleReceiptFiles(files)}
|
||||||
|
accept={{
|
||||||
|
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
||||||
|
"application/pdf": [".pdf"],
|
||||||
|
}}
|
||||||
|
maxFiles={5}
|
||||||
|
maxSize={10 * 1024 * 1024}
|
||||||
|
disabled={uploadReceipt.isPending}
|
||||||
|
placeholder="Drop receipts here"
|
||||||
|
description="Images or PDF, up to 10MB each"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Save the expense first, then you can attach receipts.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
@@ -460,7 +676,6 @@ export default function ExpensesPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete dialog */}
|
|
||||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DataTable } from "~/components/data/data-table";
|
import { DataTable } from "~/components/data/data-table";
|
||||||
|
import {
|
||||||
|
formatLineItemDetail,
|
||||||
|
isFixedLineItem,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<p className="font-medium">{item.description}</p>
|
<p className="font-medium">{item.description}</p>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{formatDate(item.date)} · {item.hours}h @{" "}
|
{formatDate(item.date)} ·{" "}
|
||||||
{formatCurrency(item.rate)}/hr
|
{formatLineItemDetail(item.hours, item.rate, formatCurrency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -69,9 +73,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "hours",
|
accessorKey: "hours",
|
||||||
header: "Hours",
|
header: "Hours",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="text-right">{row.getValue("hours")}</div>
|
const hours = row.getValue<number>("hours");
|
||||||
),
|
return (
|
||||||
|
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
@@ -80,9 +87,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "rate",
|
accessorKey: "rate",
|
||||||
header: "Rate",
|
header: "Rate",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
const item = row.original;
|
||||||
),
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
{isFixedLineItem(item.hours)
|
||||||
|
? "—"
|
||||||
|
: `${formatCurrency(item.rate)}/hr`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export function PDFDownloadButton({
|
|||||||
await generateInvoicePDF(pdfData, {
|
await generateInvoicePDF(pdfData, {
|
||||||
pdfTemplate: pdfSettings?.pdfTemplate,
|
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||||
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||||
|
pdfFontFamily: pdfSettings?.pdfFontFamily,
|
||||||
|
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
|
||||||
pdfFooterText: pdfSettings?.pdfFooterText,
|
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||||
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||||
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||||
|
|||||||
@@ -51,10 +51,14 @@ function toNumericChartValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
|
const [businessFilter, setBusinessFilter] = useState("all");
|
||||||
|
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||||
const { data: invoices = [], isLoading: invoicesLoading } =
|
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||||
api.invoices.getAll.useQuery();
|
api.invoices.getAll.useQuery();
|
||||||
const { data: expenses = [], isLoading: expensesLoading } =
|
const { data: expenses = [], isLoading: expensesLoading } =
|
||||||
api.expenses.getAll.useQuery();
|
api.expenses.getAll.useQuery(
|
||||||
|
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||||
|
);
|
||||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
const isLoading = invoicesLoading || expensesLoading;
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
@@ -62,9 +66,14 @@ export default function ReportsPage() {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
|
const filteredInvoices = useMemo(() => {
|
||||||
|
if (businessFilter === "all") return invoices;
|
||||||
|
return invoices.filter((inv) => inv.businessId === businessFilter);
|
||||||
|
}, [invoices, businessFilter]);
|
||||||
|
|
||||||
// Overview data (last 12 months)
|
// Overview data (last 12 months)
|
||||||
const overviewData = useMemo(() => {
|
const overviewData = useMemo(() => {
|
||||||
if (!invoices.length) return null;
|
if (!filteredInvoices.length) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monthMap: Record<string, number> = {};
|
const monthMap: Record<string, number> = {};
|
||||||
@@ -78,7 +87,7 @@ export default function ReportsPage() {
|
|||||||
let totalPending = 0;
|
let totalPending = 0;
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
|
|
||||||
for (const inv of invoices) {
|
for (const inv of filteredInvoices) {
|
||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -102,7 +111,7 @@ export default function ReportsPage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
for (const inv of invoices) {
|
for (const inv of filteredInvoices) {
|
||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -126,7 +135,7 @@ export default function ReportsPage() {
|
|||||||
paid: 0,
|
paid: 0,
|
||||||
overdue: 0,
|
overdue: 0,
|
||||||
};
|
};
|
||||||
for (const inv of invoices) {
|
for (const inv of filteredInvoices) {
|
||||||
const s = getEffectiveInvoiceStatus(
|
const s = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -142,13 +151,13 @@ export default function ReportsPage() {
|
|||||||
totalHours,
|
totalHours,
|
||||||
statusCount,
|
statusCount,
|
||||||
};
|
};
|
||||||
}, [invoices]);
|
}, [filteredInvoices]);
|
||||||
|
|
||||||
// Tax summary for selected year
|
// Tax summary for selected year
|
||||||
const taxData = useMemo(() => {
|
const taxData = useMemo(() => {
|
||||||
const year = parseInt(taxYear);
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
const yearInvoices = invoices.filter((inv) => {
|
const yearInvoices = filteredInvoices.filter((inv) => {
|
||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
@@ -224,20 +233,20 @@ export default function ReportsPage() {
|
|||||||
yearInvoices,
|
yearInvoices,
|
||||||
yearExpenses,
|
yearExpenses,
|
||||||
};
|
};
|
||||||
}, [invoices, expenses, taxYear]);
|
}, [filteredInvoices, expenses, taxYear]);
|
||||||
|
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo(() => {
|
||||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
for (const inv of invoices)
|
for (const inv of filteredInvoices)
|
||||||
years.add(new Date(inv.issueDate).getFullYear());
|
years.add(new Date(inv.issueDate).getFullYear());
|
||||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
return Array.from(years).sort((a, b) => b - a);
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
}, [invoices, expenses, currentYear]);
|
}, [filteredInvoices, expenses, currentYear]);
|
||||||
|
|
||||||
const avgInvoice =
|
const avgInvoice =
|
||||||
invoices.length > 0
|
filteredInvoices.length > 0
|
||||||
? (overviewData?.totalRevenue ?? 0) /
|
? (overviewData?.totalRevenue ?? 0) /
|
||||||
(invoices.filter(
|
(filteredInvoices.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
i.status as StoredInvoiceStatus,
|
i.status as StoredInvoiceStatus,
|
||||||
@@ -335,6 +344,23 @@ export default function ReportsPage() {
|
|||||||
description="Revenue and tax analytics"
|
description="Revenue and tax analytics"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">Business</span>
|
||||||
|
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder="All businesses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All businesses</SelectItem>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PageTabs defaultValue="overview">
|
<PageTabs defaultValue="overview">
|
||||||
<PageTabsList>
|
<PageTabsList>
|
||||||
<PageTabsTrigger value="overview" className="gap-1.5">
|
<PageTabsTrigger value="overview" className="gap-1.5">
|
||||||
@@ -573,7 +599,7 @@ export default function ReportsPage() {
|
|||||||
<div
|
<div
|
||||||
className="bg-primary h-full rounded-full"
|
className="bg-primary h-full rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,7 +610,7 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{invoices.length === 0 && (
|
{filteredInvoices.length === 0 && (
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No invoices yet.
|
No invoices yet.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
import { brand, colorModes } from "~/lib/branding";
|
import { brand, colorModes } from "~/lib/branding";
|
||||||
import type { PdfTemplate } from "~/lib/appearance";
|
import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||||
|
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
|
||||||
import { ApiAccessSettings } from "./api-access-settings";
|
import { ApiAccessSettings } from "./api-access-settings";
|
||||||
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
||||||
|
|
||||||
@@ -146,6 +147,7 @@ export function SettingsContent({
|
|||||||
|
|
||||||
const { data: session } = useAuthSession();
|
const { data: session } = useAuthSession();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [nameInitialized, setNameInitialized] = useState(false);
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||||
const [importData, setImportData] = useState("");
|
const [importData, setImportData] = useState("");
|
||||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
@@ -177,6 +179,8 @@ export function SettingsContent({
|
|||||||
const savePdfSettings = (patch: {
|
const savePdfSettings = (patch: {
|
||||||
pdfTemplate?: PdfTemplate;
|
pdfTemplate?: PdfTemplate;
|
||||||
pdfAccentColor?: string;
|
pdfAccentColor?: string;
|
||||||
|
pdfFontFamily?: PdfFontFamily;
|
||||||
|
pdfNumericFontFamily?: PdfFontFamily;
|
||||||
pdfFooterText?: string;
|
pdfFooterText?: string;
|
||||||
pdfShowLogo?: boolean;
|
pdfShowLogo?: boolean;
|
||||||
pdfShowPageNumbers?: boolean;
|
pdfShowPageNumbers?: boolean;
|
||||||
@@ -217,7 +221,7 @@ export function SettingsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: profile, refetch: refetchProfile } =
|
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
|
||||||
api.settings.getProfile.useQuery();
|
api.settings.getProfile.useQuery();
|
||||||
const isAdmin = profile?.role === "admin";
|
const isAdmin = profile?.role === "admin";
|
||||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||||
@@ -405,16 +409,13 @@ export function SettingsContent({
|
|||||||
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
|
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set initial name value when profile loads
|
// Set initial name value once when profile loads
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (profile?.name && !name) {
|
if (nameInitialized || !profileFetched) return;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
|
||||||
setName(profile.name);
|
setName(profile?.name ?? session?.user?.name ?? "");
|
||||||
}
|
setNameInitialized(true);
|
||||||
if (session?.user) {
|
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
|
||||||
setName(session.user.name ?? "");
|
|
||||||
}
|
|
||||||
}, [session, profile?.name, name]);
|
|
||||||
|
|
||||||
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
||||||
|
|
||||||
@@ -790,6 +791,73 @@ export function SettingsContent({
|
|||||||
className="mt-0"
|
className="mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Body Text Font</Label>
|
||||||
|
<Select
|
||||||
|
value={pdfSettings?.pdfFontFamily ?? "sans"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
savePdfSettings({
|
||||||
|
pdfFontFamily: value as PdfFontFamily,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={updatePdfSettingsMutation.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pdfFontFamilyOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
pdfFontFamilyOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.value ===
|
||||||
|
(pdfSettings?.pdfFontFamily ?? "sans"),
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Numbers Font</Label>
|
||||||
|
<Select
|
||||||
|
value={pdfSettings?.pdfNumericFontFamily ?? "mono"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
savePdfSettings({
|
||||||
|
pdfNumericFontFamily: value as PdfFontFamily,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={updatePdfSettingsMutation.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pdfFontFamilyOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
Used for dates, hours, rates, and totals.{" "}
|
||||||
|
{
|
||||||
|
pdfFontFamilyOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.value ===
|
||||||
|
(pdfSettings?.pdfNumericFontFamily ?? "mono"),
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -847,6 +915,9 @@ export function SettingsContent({
|
|||||||
settings={{
|
settings={{
|
||||||
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
|
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
|
||||||
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
|
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
|
||||||
|
pdfFontFamily: pdfSettings?.pdfFontFamily ?? "sans",
|
||||||
|
pdfNumericFontFamily:
|
||||||
|
pdfSettings?.pdfNumericFontFamily ?? "mono",
|
||||||
pdfFooterText:
|
pdfFooterText:
|
||||||
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
|
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
|
||||||
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
|
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { TimeEntriesHistory } from "~/components/time-clock/time-entries-history";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function TimeClockEntriesPage() {
|
||||||
|
void api.timeEntries.getAll.prefetch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPage>
|
||||||
|
<DashboardPageHeader
|
||||||
|
title="Time entries"
|
||||||
|
description="Your completed time tracking history"
|
||||||
|
>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/dashboard/time-clock">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Time clock
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</DashboardPageHeader>
|
||||||
|
<HydrateClient>
|
||||||
|
<TimeEntriesHistory />
|
||||||
|
</HydrateClient>
|
||||||
|
</DashboardPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||||
|
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr
|
{formatLineItemDetail(
|
||||||
|
item.hours,
|
||||||
|
item.rate,
|
||||||
|
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-gray-900 shrink-0">
|
<p className="font-semibold text-gray-900 shrink-0">
|
||||||
|
|||||||
@@ -108,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
// Fetch business data if editing
|
// Fetch business data if editing
|
||||||
const { data: business, isLoading: isLoadingBusiness } =
|
const { data: business, isLoading: isLoadingBusiness } =
|
||||||
api.businesses.getById.useQuery(
|
api.businesses.getById.useQuery(
|
||||||
{ id: businessId! },
|
{ id: businessId! },
|
||||||
{ enabled: mode === "edit" && !!businessId },
|
{
|
||||||
|
enabled: mode === "edit" && !!businessId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch email configuration if editing
|
// Fetch email configuration if editing
|
||||||
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
||||||
api.businesses.getEmailConfig.useQuery(
|
api.businesses.getEmailConfig.useQuery(
|
||||||
{ id: businessId! },
|
{ id: businessId! },
|
||||||
{ enabled: mode === "edit" && !!businessId },
|
{
|
||||||
|
enabled: mode === "edit" && !!businessId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update email configuration mutation
|
// Update email configuration mutation
|
||||||
@@ -142,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load business data when editing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (business && mode === "edit") {
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
|
||||||
|
setInitialized(false);
|
||||||
|
setIsDirty(false);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
}, [businessId]);
|
||||||
|
|
||||||
|
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
business &&
|
||||||
|
mode === "edit" &&
|
||||||
|
!initialized &&
|
||||||
|
!isLoadingEmailConfig
|
||||||
|
) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
name: business.name,
|
name: business.name,
|
||||||
@@ -164,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
resendDomain: emailConfig?.resendDomain ?? "",
|
resendDomain: emailConfig?.resendDomain ?? "",
|
||||||
emailFromName: emailConfig?.emailFromName ?? "",
|
emailFromName: emailConfig?.emailFromName ?? "",
|
||||||
});
|
});
|
||||||
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [business, emailConfig, mode]);
|
}, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string | boolean) => {
|
const handleInputChange = (field: string, value: string | boolean) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|||||||
@@ -90,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
// Fetch client data if editing
|
// Fetch client data if editing
|
||||||
const { data: client, isLoading: isLoadingClient } =
|
const { data: client, isLoading: isLoadingClient } =
|
||||||
api.clients.getById.useQuery(
|
api.clients.getById.useQuery(
|
||||||
{ id: clientId! },
|
{ id: clientId! },
|
||||||
{ enabled: mode === "edit" && !!clientId },
|
{
|
||||||
|
enabled: mode === "edit" && !!clientId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const createClient = api.clients.create.useMutation({
|
const createClient = api.clients.create.useMutation({
|
||||||
@@ -118,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load client data when editing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && mode === "edit") {
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
|
||||||
|
setInitialized(false);
|
||||||
|
setIsDirty(false);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (client && mode === "edit" && !initialized) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
name: client.name,
|
name: client.name,
|
||||||
@@ -135,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||||
currency: client.currency ?? "USD",
|
currency: client.currency ?? "USD",
|
||||||
});
|
});
|
||||||
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [client, mode]);
|
}, [client, mode, initialized]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string | number | null) => {
|
const handleInputChange = (field: string, value: string | number | null) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
|
|
||||||
interface EmailPreviewProps {
|
interface EmailPreviewProps {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -54,7 +55,7 @@ export function EmailPreview({
|
|||||||
const calculateTotal = () => {
|
const calculateTotal = () => {
|
||||||
if (!invoice?.items) return 0;
|
if (!invoice?.items) return 0;
|
||||||
const subtotal = invoice.items.reduce(
|
const subtotal = invoice.items.reduce(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
@@ -83,7 +84,7 @@ export function EmailPreview({
|
|||||||
description: item.description ?? "Service",
|
description: item.description ?? "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount ?? item.hours * item.rate,
|
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
},
|
},
|
||||||
customContent: content,
|
customContent: content,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
|
|
||||||
interface InvoiceItem {
|
interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
|
|||||||
Total
|
Total
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary text-lg font-bold">
|
<span className="text-primary text-lg font-bold">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ import {
|
|||||||
import { STATUS_OPTIONS } from "./invoice/types";
|
import { STATUS_OPTIONS } from "./invoice/types";
|
||||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
|
import {
|
||||||
|
applyBillingTypeChange,
|
||||||
|
calculateLineItemAmount,
|
||||||
|
getLineItemBillingType,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
||||||
|
|
||||||
import { CountUp } from "~/components/ui/count-up";
|
import { CountUp } from "~/components/ui/count-up";
|
||||||
@@ -123,6 +128,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
|
|||||||
hours: 1,
|
hours: 1,
|
||||||
rate: 0,
|
rate: 0,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -180,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
|
billingType: getLineItemBillingType(item.hours),
|
||||||
})) || [];
|
})) || [];
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -206,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
hours: 1,
|
hours: 1,
|
||||||
rate: 0,
|
rate: 0,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -238,7 +246,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
const totals = React.useMemo(() => {
|
const totals = React.useMemo(() => {
|
||||||
const subtotal = formData.items.reduce(
|
const subtotal = formData.items.reduce(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||||
@@ -268,6 +276,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: item.description || "Service",
|
description: item.description || "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[formData],
|
[formData],
|
||||||
@@ -298,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
hours: 1,
|
hours: 1,
|
||||||
rate: prev.defaultHourlyRate ?? 0,
|
rate: prev.defaultHourlyRate ?? 0,
|
||||||
amount: prev.defaultHourlyRate ?? 0,
|
amount: prev.defaultHourlyRate ?? 0,
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
@@ -313,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: parsed.description,
|
description: parsed.description,
|
||||||
hours: parsed.hours ?? 1,
|
hours: parsed.hours ?? 1,
|
||||||
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||||
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0),
|
amount: calculateLineItemAmount(
|
||||||
|
parsed.hours ?? 1,
|
||||||
|
parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||||
|
),
|
||||||
|
billingType: "hourly",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
@@ -333,14 +347,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((item, i) => {
|
items: prev.items.map((item, i) => {
|
||||||
if (i === idx) {
|
if (i !== idx) return item;
|
||||||
|
|
||||||
|
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
||||||
|
const next = applyBillingTypeChange(value, item);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...next,
|
||||||
|
billingType: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const updated = { ...item, [field]: value };
|
const updated = { ...item, [field]: value };
|
||||||
if (field === "hours" || field === "rate") {
|
if (field === "hours" || field === "rate") {
|
||||||
updated.amount = updated.hours * updated.rate;
|
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
|
||||||
|
updated.billingType = getLineItemBillingType(updated.hours);
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -435,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: i.description,
|
description: i.description,
|
||||||
hours: i.hours,
|
hours: i.hours,
|
||||||
rate: i.rate,
|
rate: i.rate,
|
||||||
amount: i.hours * i.rate,
|
amount: calculateLineItemAmount(i.hours, i.rate),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
||||||
@@ -771,7 +794,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<span className="text-muted-foreground">Hours</span>
|
<span className="text-muted-foreground">Hours</span>
|
||||||
<span className="font-mono text-xl font-semibold">
|
<span className="font-mono text-xl font-semibold">
|
||||||
<CountUp
|
<CountUp
|
||||||
value={formData.items.reduce((s, i) => s + i.hours, 0)}
|
value={formData.items.reduce(
|
||||||
|
(s, i) => s + (i.hours > 0 ? i.hours : 0),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
suffix="h"
|
suffix="h"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -898,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.hours * item.rate,
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
|
|||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
calculateLineItemAmount,
|
||||||
|
getLineItemBillingType,
|
||||||
|
type LineItemBillingType,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
import {
|
import {
|
||||||
useLineItemSuggestions,
|
useLineItemSuggestions,
|
||||||
type LineItemSuggestion,
|
type LineItemSuggestion,
|
||||||
} from "~/hooks/use-line-item-suggestions";
|
} from "~/hooks/use-line-item-suggestions";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
|
||||||
interface InvoiceItem {
|
interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +35,7 @@ interface InvoiceItem {
|
|||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
billingType?: LineItemBillingType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceLineItemsProps {
|
interface InvoiceLineItemsProps {
|
||||||
@@ -149,13 +162,21 @@ function DescriptionAutocomplete({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LINE_ITEM_GRID =
|
||||||
|
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
|
||||||
|
|
||||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
||||||
|
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||||
|
const isFixed = billingType === "fixed";
|
||||||
|
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
"group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||||
|
LINE_ITEM_GRID,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -177,6 +198,25 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={billingType}
|
||||||
|
onValueChange={(value: LineItemBillingType) =>
|
||||||
|
onUpdate(index, "billingType", value)
|
||||||
|
}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isFixed ? (
|
||||||
|
<span className="text-muted-foreground text-center text-xs">—</span>
|
||||||
|
) : (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.hours}
|
value={item.hours}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
@@ -187,6 +227,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
suffix="h"
|
suffix="h"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${lineTotal.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -235,6 +276,10 @@ function MobileLineItem({
|
|||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
|
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||||
|
const isFixed = billingType === "fixed";
|
||||||
|
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`invoice-item-${index}-mobile`}
|
id={`invoice-item-${index}-mobile`}
|
||||||
@@ -264,6 +309,22 @@ function MobileLineItem({
|
|||||||
inputClassName="h-8 px-2 text-xs"
|
inputClassName="h-8 px-2 text-xs"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
value={billingType}
|
||||||
|
onValueChange={(value: LineItemBillingType) =>
|
||||||
|
onUpdate(index, "billingType", value)
|
||||||
|
}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!isFixed ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.hours}
|
value={item.hours}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
@@ -274,6 +335,7 @@ function MobileLineItem({
|
|||||||
suffix="h"
|
suffix="h"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
onChange={(value) => onUpdate(index, "rate", value)}
|
onChange={(value) => onUpdate(index, "rate", value)}
|
||||||
@@ -285,7 +347,7 @@ function MobileLineItem({
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${lineTotal.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
|
|||||||
onUpdateItem(index, "description", s.description);
|
onUpdateItem(index, "description", s.description);
|
||||||
onUpdateItem(index, "hours", s.hours);
|
onUpdateItem(index, "hours", s.hours);
|
||||||
onUpdateItem(index, "rate", s.rate);
|
onUpdateItem(index, "rate", s.rate);
|
||||||
|
onUpdateItem(index, "billingType", "hourly");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setQueriedIndex(null);
|
setQueriedIndex(null);
|
||||||
}
|
}
|
||||||
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
|
|||||||
) : null}
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
<div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
|
||||||
<span>Date</span>
|
<span>Date</span>
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
|
<span className="text-center">Type</span>
|
||||||
<span className="text-center">Hours</span>
|
<span className="text-center">Hours</span>
|
||||||
<span className="text-center">Rate</span>
|
<span className="text-center">Rate</span>
|
||||||
<span className="text-right">Amount</span>
|
<span className="text-right">Amount</span>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { type RouterOutputs } from "~/trpc/react";
|
|||||||
export type ClientType = RouterOutputs["clients"]["getAll"][number];
|
export type ClientType = RouterOutputs["clients"]["getAll"][number];
|
||||||
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
||||||
|
|
||||||
|
import type { LineItemBillingType } from "~/lib/invoice-line-item";
|
||||||
|
|
||||||
export interface InvoiceItem {
|
export interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -10,6 +12,7 @@ export interface InvoiceItem {
|
|||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
billingType: LineItemBillingType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceFormData {
|
export interface InvoiceFormData {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const SPECIAL_SEGMENTS: Record<string, string> = {
|
|||||||
import: "Import",
|
import: "Import",
|
||||||
export: "Export",
|
export: "Export",
|
||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
|
entries: "All entries",
|
||||||
|
"time-clock": "Time clock",
|
||||||
};
|
};
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
resolveEffectiveHourlyRate,
|
resolveEffectiveHourlyRate,
|
||||||
startedAtFromMinutesAgo,
|
startedAtFromMinutesAgo,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
|
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||||
|
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||||
|
|
||||||
const FEATURED_CLIENT_COUNT = 4;
|
const FEATURED_CLIENT_COUNT = 4;
|
||||||
|
|
||||||
@@ -46,26 +48,6 @@ export type TimeClockPanelProps = {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function invoiceLabel(inv: {
|
|
||||||
invoicePrefix: string | null;
|
|
||||||
invoiceNumber: string;
|
|
||||||
}) {
|
|
||||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryHref(entry: {
|
|
||||||
invoiceId: string | null;
|
|
||||||
clientId: string | null;
|
|
||||||
invoice?: { id: string } | null;
|
|
||||||
client?: { id: string } | null;
|
|
||||||
}): string | null {
|
|
||||||
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
|
|
||||||
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
|
|
||||||
const clientId = entry.clientId ?? entry.client?.id;
|
|
||||||
if (clientId) return `/dashboard/clients/${clientId}`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientChip({
|
function ClientChip({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
@@ -145,6 +127,10 @@ export function TimeClockPanel({
|
|||||||
const last = getLastTimeClockClientId();
|
const last = getLastTimeClockClientId();
|
||||||
if (last) ids.push(last);
|
if (last) ids.push(last);
|
||||||
|
|
||||||
|
if (running?.clientId && !ids.includes(running.clientId)) {
|
||||||
|
ids.unshift(running.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of todayEntries ?? []) {
|
for (const entry of todayEntries ?? []) {
|
||||||
if (entry.clientId && !ids.includes(entry.clientId)) {
|
if (entry.clientId && !ids.includes(entry.clientId)) {
|
||||||
ids.push(entry.clientId);
|
ids.push(entry.clientId);
|
||||||
@@ -157,7 +143,7 @@ export function TimeClockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ids;
|
return ids;
|
||||||
}, [clients, todayEntries]);
|
}, [clients, todayEntries, running]);
|
||||||
|
|
||||||
const visibleClients = useMemo(() => {
|
const visibleClients = useMemo(() => {
|
||||||
if (!clients?.length) return [];
|
if (!clients?.length) return [];
|
||||||
@@ -191,6 +177,14 @@ export function TimeClockPanel({
|
|||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.timeEntries.getRunning.invalidate();
|
||||||
|
void utils.invoices.getBillable.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const message = describeClockOutOutcome({
|
const message = describeClockOutOutcome({
|
||||||
@@ -227,6 +221,11 @@ export function TimeClockPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleClientChange(value: string) {
|
function handleClientChange(value: string) {
|
||||||
|
if (running) {
|
||||||
|
updateRunning.mutate({ clientId: value, invoiceId: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setClientId(value);
|
setClientId(value);
|
||||||
setInvoiceId("");
|
setInvoiceId("");
|
||||||
setLastTimeClockClientId(value);
|
setLastTimeClockClientId(value);
|
||||||
@@ -234,6 +233,15 @@ export function TimeClockPanel({
|
|||||||
setRate(client?.defaultHourlyRate ?? 0);
|
setRate(client?.defaultHourlyRate ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleInvoiceChange(value: string) {
|
||||||
|
const next = value === "__none__" ? "" : value;
|
||||||
|
if (running) {
|
||||||
|
updateRunning.mutate({ invoiceId: next });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInvoiceId(next);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveStartedAt(): Date | undefined {
|
function resolveStartedAt(): Date | undefined {
|
||||||
if (startMode === "now") return undefined;
|
if (startMode === "now") return undefined;
|
||||||
if (startMode === "pick") {
|
if (startMode === "pick") {
|
||||||
@@ -293,6 +301,8 @@ export function TimeClockPanel({
|
|||||||
|
|
||||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||||
|
const activeClientId = running ? (running.clientId ?? "") : clientId;
|
||||||
|
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||||
@@ -347,7 +357,7 @@ export function TimeClockPanel({
|
|||||||
<ClientChip
|
<ClientChip
|
||||||
key={client.id}
|
key={client.id}
|
||||||
label={client.name}
|
label={client.name}
|
||||||
active={clientId === client.id}
|
active={activeClientId === client.id}
|
||||||
onClick={() => handleClientChange(client.id)}
|
onClick={() => handleClientChange(client.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -383,7 +393,7 @@ export function TimeClockPanel({
|
|||||||
<Label>Invoice</Label>
|
<Label>Invoice</Label>
|
||||||
<Select
|
<Select
|
||||||
value={invoiceId || "__none__"}
|
value={invoiceId || "__none__"}
|
||||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
onValueChange={handleInvoiceChange}
|
||||||
disabled={!clientId}
|
disabled={!clientId}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -485,6 +495,77 @@ export function TimeClockPanel({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{visibleClients.map((client) => (
|
||||||
|
<ClientChip
|
||||||
|
key={client.id}
|
||||||
|
label={client.name}
|
||||||
|
active={activeClientId === client.id}
|
||||||
|
onClick={() => handleClientChange(client.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!showAllClients && hiddenClientCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => setShowAllClients(true)}
|
||||||
|
>
|
||||||
|
+{hiddenClientCount} more
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
||||||
|
<Select
|
||||||
|
value={activeClientId || undefined}
|
||||||
|
onValueChange={handleClientChange}
|
||||||
|
disabled={updateRunning.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="Select client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients?.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Invoice</Label>
|
||||||
|
<Select
|
||||||
|
value={activeInvoiceId || "__none__"}
|
||||||
|
onValueChange={handleInvoiceChange}
|
||||||
|
disabled={!activeClientId || updateRunning.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
activeClientId
|
||||||
|
? "Draft invoice (optional)"
|
||||||
|
: "Choose a client first"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
||||||
|
{billableInvoices?.map((inv) => (
|
||||||
|
<SelectItem key={inv.id} value={inv.id}>
|
||||||
|
{invoiceLabel(inv)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -498,6 +579,7 @@ export function TimeClockPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
@@ -529,66 +611,28 @@ export function TimeClockPanel({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!compact && todayEntries && todayEntries.length > 0 ? (
|
{!compact ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8" asChild>
|
||||||
|
<Link href="/dashboard/time-clock/entries">View all entries</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{todayEntries
|
{todayEntries?.some((e) => e.endedAt) ? (
|
||||||
.filter((e) => e.endedAt)
|
<TimeEntryList entries={todayEntries} />
|
||||||
.map((entry, index, entries) => {
|
) : (
|
||||||
const href = entryHref(entry);
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
const isLast = index === entries.length - 1;
|
No entries today.{" "}
|
||||||
const rowClassName = cn(
|
|
||||||
"flex items-start justify-between gap-4 py-3",
|
|
||||||
!isLast && "border-border border-b",
|
|
||||||
);
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium">
|
|
||||||
{formatRunningTimerLabel(entry.description)}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{entry.client?.name ?? "No client"}
|
|
||||||
{entry.invoice
|
|
||||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
|
||||||
: entry.hours
|
|
||||||
? " · not on invoice"
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-sm">
|
|
||||||
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
|
|
||||||
{entry.rate ? (
|
|
||||||
<p className="text-muted-foreground">${entry.rate}/hr</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (href) {
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={entry.id}
|
href="/dashboard/time-clock/entries"
|
||||||
href={href}
|
className="text-primary hover:underline"
|
||||||
className={cn(
|
|
||||||
rowClassName,
|
|
||||||
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{content}
|
View history
|
||||||
</Link>
|
</Link>
|
||||||
);
|
</p>
|
||||||
}
|
)}
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={entry.id} className={rowClassName}>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
|
import { Clock, Play } from "lucide-react";
|
||||||
|
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
||||||
|
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
|
||||||
|
|
||||||
|
export function TimeEntriesHistory() {
|
||||||
|
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||||
|
|
||||||
|
const completedEntries = useMemo(
|
||||||
|
() => (entries ?? []).filter((e) => e.endedAt),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = useMemo(
|
||||||
|
() => groupEntriesByDate(completedEntries),
|
||||||
|
[completedEntries],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-muted-foreground p-6 text-sm">
|
||||||
|
Loading entries…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedEntries.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<EmptyState
|
||||||
|
icon={<Clock className="h-6 w-6" />}
|
||||||
|
title="No time entries yet"
|
||||||
|
description="Start the timer to track billable hours. Completed entries will show up here."
|
||||||
|
action={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/time-clock">
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Start timer
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
className="py-16"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<Card key={group.dateKey}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
|
{group.label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{group.entries.map((entry, index) => (
|
||||||
|
<TimeEntryRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
isLast={index === group.entries.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { formatRunningTimerLabel } from "~/lib/time-clock";
|
||||||
|
import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entry-display";
|
||||||
|
|
||||||
|
export function TimeEntryRow({
|
||||||
|
entry,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
entry: TimeEntryListItem;
|
||||||
|
isLast?: boolean;
|
||||||
|
}) {
|
||||||
|
const href = entryHref(entry);
|
||||||
|
const rowClassName = cn(
|
||||||
|
"flex items-start justify-between gap-4 py-3",
|
||||||
|
!isLast && "border-border border-b",
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{formatRunningTimerLabel(entry.description)}</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{entry.client?.name ?? "No client"}
|
||||||
|
{entry.invoice
|
||||||
|
? ` · ${invoiceLabel(entry.invoice)}`
|
||||||
|
: entry.hours
|
||||||
|
? " · not on invoice"
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
|
||||||
|
{entry.rate ? <p className="text-muted-foreground">${entry.rate}/hr</p> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
rowClassName,
|
||||||
|
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rowClassName}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
||||||
|
const completed = entries.filter((e) => e.endedAt);
|
||||||
|
|
||||||
|
if (completed.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{completed.map((entry, index) => (
|
||||||
|
<TimeEntryRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
isLast={index === completed.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
@@ -35,6 +35,12 @@ export const env = createEnv({
|
|||||||
DB_DISABLE_SSL: optionalEnvBoolean(),
|
DB_DISABLE_SSL: optionalEnvBoolean(),
|
||||||
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
|
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
|
||||||
CRON_SECRET: z.string().optional(),
|
CRON_SECRET: z.string().optional(),
|
||||||
|
// S3-compatible object storage (optional — local .data/receipts/ fallback when unset)
|
||||||
|
S3_ENDPOINT: z.string().url().optional(),
|
||||||
|
S3_BUCKET: z.string().optional(),
|
||||||
|
S3_ACCESS_KEY: z.string().optional(),
|
||||||
|
S3_SECRET_KEY: z.string().optional(),
|
||||||
|
S3_REGION: z.string().optional(),
|
||||||
// SSO / Authentik (optional)
|
// SSO / Authentik (optional)
|
||||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||||
@@ -76,6 +82,11 @@ export const env = createEnv({
|
|||||||
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
||||||
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
|
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
|
||||||
CRON_SECRET: process.env.CRON_SECRET,
|
CRON_SECRET: process.env.CRON_SECRET,
|
||||||
|
S3_ENDPOINT: process.env.S3_ENDPOINT,
|
||||||
|
S3_BUCKET: process.env.S3_BUCKET,
|
||||||
|
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||||
|
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
||||||
|
S3_REGION: process.env.S3_REGION,
|
||||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
pdfFontFamilySchema,
|
||||||
|
type PdfFontFamily,
|
||||||
|
} from "~/lib/pdf-fonts";
|
||||||
|
|
||||||
export const colorModeValues = ["light", "dark", "system"] as const;
|
export const colorModeValues = ["light", "dark", "system"] as const;
|
||||||
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
||||||
@@ -6,6 +10,8 @@ export const pdfTemplateValues = ["classic", "minimal"] as const;
|
|||||||
export const colorModeSchema = z.enum(colorModeValues);
|
export const colorModeSchema = z.enum(colorModeValues);
|
||||||
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
||||||
|
|
||||||
|
export { pdfFontFamilySchema, type PdfFontFamily };
|
||||||
|
|
||||||
export type ColorMode = z.infer<typeof colorModeSchema>;
|
export type ColorMode = z.infer<typeof colorModeSchema>;
|
||||||
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
||||||
|
|
||||||
@@ -14,6 +20,8 @@ export const defaultColorMode: ColorMode = "system";
|
|||||||
export const defaultPdfSettings = {
|
export const defaultPdfSettings = {
|
||||||
pdfTemplate: "classic" as PdfTemplate,
|
pdfTemplate: "classic" as PdfTemplate,
|
||||||
pdfAccentColor: "#111827",
|
pdfAccentColor: "#111827",
|
||||||
|
pdfFontFamily: "sans" as PdfFontFamily,
|
||||||
|
pdfNumericFontFamily: "mono" as PdfFontFamily,
|
||||||
pdfFooterText: "Professional Invoicing",
|
pdfFooterText: "Professional Invoicing",
|
||||||
pdfShowLogo: true,
|
pdfShowLogo: true,
|
||||||
pdfShowPageNumbers: true,
|
pdfShowPageNumbers: true,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { db } from "~/server/db";
|
||||||
|
import { auditLog } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export type AuditAction =
|
||||||
|
| "user.profile_updated"
|
||||||
|
| "user.role_updated"
|
||||||
|
| "user.password_reset_sent"
|
||||||
|
| "platform.pdf_settings_updated";
|
||||||
|
|
||||||
|
export type AuditTargetType = "user" | "platform";
|
||||||
|
|
||||||
|
type LogAuditEventInput = {
|
||||||
|
actorUserId: string;
|
||||||
|
action: AuditAction;
|
||||||
|
targetType: AuditTargetType;
|
||||||
|
targetId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function logAuditEvent(input: LogAuditEventInput): Promise<void> {
|
||||||
|
await db.insert(auditLog).values({
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
action: input.action,
|
||||||
|
targetType: input.targetType,
|
||||||
|
targetId: input.targetId,
|
||||||
|
metadata: input.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { type ColorMode } from "~/lib/appearance";
|
import { type ColorMode } from "~/lib/appearance";
|
||||||
|
|
||||||
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||||
export {
|
export {
|
||||||
colorModeSchema,
|
colorModeSchema,
|
||||||
defaultColorMode,
|
defaultColorMode,
|
||||||
defaultPdfSettings,
|
defaultPdfSettings,
|
||||||
|
pdfFontFamilySchema,
|
||||||
pdfTemplateSchema,
|
pdfTemplateSchema,
|
||||||
} from "~/lib/appearance";
|
} from "~/lib/appearance";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type LineItemBillingType = "hourly" | "fixed";
|
||||||
|
|
||||||
|
export function isFixedLineItem(hours: number): boolean {
|
||||||
|
return hours === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLineItemBillingType(hours: number): LineItemBillingType {
|
||||||
|
return isFixedLineItem(hours) ? "fixed" : "hourly";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateLineItemAmount(hours: number, rate: number): number {
|
||||||
|
return isFixedLineItem(hours) ? rate : hours * rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLineItemDetail(
|
||||||
|
hours: number,
|
||||||
|
rate: number,
|
||||||
|
formatCurrency: (amount: number) => string,
|
||||||
|
): string {
|
||||||
|
if (isFixedLineItem(hours)) {
|
||||||
|
return "Fixed amount";
|
||||||
|
}
|
||||||
|
return `${hours}h @ ${formatCurrency(rate)}/hr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBillingTypeChange(
|
||||||
|
billingType: LineItemBillingType,
|
||||||
|
current: { hours: number; rate: number },
|
||||||
|
): { hours: number; rate: number; amount: number } {
|
||||||
|
if (billingType === "fixed") {
|
||||||
|
const amount = calculateLineItemAmount(current.hours, current.rate);
|
||||||
|
return { hours: 0, rate: amount, amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = current.hours > 0 ? current.hours : 1;
|
||||||
|
const amount = calculateLineItemAmount(hours, current.rate);
|
||||||
|
return { hours, rate: current.rate, amount };
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
|
|||||||
pathname.startsWith("/dashboard/businesses")
|
pathname.startsWith("/dashboard/businesses")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (href === "/dashboard/time-clock") {
|
||||||
|
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
|
||||||
|
}
|
||||||
return pathname === href;
|
return pathname === href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
||||||
|
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
||||||
|
|
||||||
|
function isS3Configured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.S3_BUCKET &&
|
||||||
|
process.env.S3_ACCESS_KEY &&
|
||||||
|
process.env.S3_SECRET_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageBackend(): "s3" | "local" {
|
||||||
|
return isS3Configured() ? "s3" : "local";
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3Module = typeof import("@aws-sdk/client-s3");
|
||||||
|
|
||||||
|
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||||
|
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||||
|
|
||||||
|
async function getS3() {
|
||||||
|
if (!s3ModulePromise) {
|
||||||
|
s3ModulePromise = import("@aws-sdk/client-s3");
|
||||||
|
}
|
||||||
|
const mod = await s3ModulePromise;
|
||||||
|
if (!s3Client) {
|
||||||
|
s3Client = new mod.S3Client({
|
||||||
|
region: process.env.S3_REGION ?? "us-east-1",
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { client: s3Client, ...mod };
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPathForKey(key: string) {
|
||||||
|
return path.join(LOCAL_RECEIPTS_DIR, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putObject(
|
||||||
|
key: string,
|
||||||
|
body: Buffer,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (isS3Configured()) {
|
||||||
|
const { client, PutObjectCommand } = await getS3();
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET!,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = localPathForKey(key);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getObject(key: string): Promise<Buffer> {
|
||||||
|
if (isS3Configured()) {
|
||||||
|
const { client, GetObjectCommand } = await getS3();
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET!,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const bytes = await response.Body?.transformToByteArray();
|
||||||
|
if (!bytes) {
|
||||||
|
throw new Error("Empty object body");
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFile(localPathForKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
|
if (isS3Configured()) {
|
||||||
|
const { client, DeleteObjectCommand } = await getS3();
|
||||||
|
await client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET!,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(localPathForKey(key));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function isAllowedReceiptMime(mimeType: string): boolean {
|
||||||
|
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||||
|
return normalized === "application/pdf" || normalized.startsWith("image/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export type PasswordResetResult = {
|
||||||
|
success: boolean;
|
||||||
|
emailSent: boolean;
|
||||||
|
userEmail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendPasswordResetForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<PasswordResetResult> {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { id: true, email: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, emailSent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ resetToken, resetTokenExpiry })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
if (!env.RESEND_API_KEY) {
|
||||||
|
console.warn(
|
||||||
|
"Password reset requested, but RESEND_API_KEY is not configured.",
|
||||||
|
);
|
||||||
|
return { success: true, emailSent: false, userEmail: user.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resend = new Resend(env.RESEND_API_KEY);
|
||||||
|
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
|
||||||
|
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.name ?? undefined,
|
||||||
|
resetToken,
|
||||||
|
resetUrl,
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: `beenvoice <noreply@${fromDomain}>`,
|
||||||
|
to: user.email,
|
||||||
|
subject: emailTemplate.subject,
|
||||||
|
html: emailTemplate.html,
|
||||||
|
text: emailTemplate.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, emailSent: true, userEmail: user.email };
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error("Failed to send password reset email:", emailError);
|
||||||
|
return { success: true, emailSent: false, userEmail: user.email };
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-38
@@ -6,9 +6,19 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
pdf,
|
pdf,
|
||||||
|
type Styles,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
import {
|
||||||
|
isFixedLineItem,
|
||||||
|
} from "~/lib/invoice-line-item";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import {
|
||||||
|
type PdfFontFamily,
|
||||||
|
type ResolvedPdfFonts,
|
||||||
|
pdfFontCacheKey,
|
||||||
|
resolvePdfFonts,
|
||||||
|
} from "~/lib/pdf-fonts";
|
||||||
|
|
||||||
// Fallback download function for better browser compatibility
|
// Fallback download function for better browser compatibility
|
||||||
function downloadBlob(blob: Blob, filename: string): void {
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
@@ -101,6 +111,8 @@ export interface InvoiceData {
|
|||||||
export interface PDFGenerationSettings {
|
export interface PDFGenerationSettings {
|
||||||
pdfTemplate?: "classic" | "minimal";
|
pdfTemplate?: "classic" | "minimal";
|
||||||
pdfAccentColor?: string;
|
pdfAccentColor?: string;
|
||||||
|
pdfFontFamily?: PdfFontFamily;
|
||||||
|
pdfNumericFontFamily?: PdfFontFamily;
|
||||||
pdfFooterText?: string;
|
pdfFooterText?: string;
|
||||||
pdfShowLogo?: boolean;
|
pdfShowLogo?: boolean;
|
||||||
pdfShowPageNumbers?: boolean;
|
pdfShowPageNumbers?: boolean;
|
||||||
@@ -109,6 +121,8 @@ export interface PDFGenerationSettings {
|
|||||||
const defaultPDFSettings: Required<PDFGenerationSettings> = {
|
const defaultPDFSettings: Required<PDFGenerationSettings> = {
|
||||||
pdfTemplate: "classic",
|
pdfTemplate: "classic",
|
||||||
pdfAccentColor: "#111827",
|
pdfAccentColor: "#111827",
|
||||||
|
pdfFontFamily: "sans",
|
||||||
|
pdfNumericFontFamily: "mono",
|
||||||
pdfFooterText: "Professional Invoicing",
|
pdfFooterText: "Professional Invoicing",
|
||||||
pdfShowLogo: true,
|
pdfShowLogo: true,
|
||||||
pdfShowPageNumbers: true,
|
pdfShowPageNumbers: true,
|
||||||
@@ -118,7 +132,95 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
|||||||
return { ...defaultPDFSettings, ...settings };
|
return { ...defaultPDFSettings, ...settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function mapLegacyPdfFont(
|
||||||
|
fontFamily: string,
|
||||||
|
fonts: ResolvedPdfFonts,
|
||||||
|
): string {
|
||||||
|
switch (fontFamily) {
|
||||||
|
case "Helvetica-Bold":
|
||||||
|
return fonts.bold;
|
||||||
|
case "Helvetica":
|
||||||
|
return fonts.regular;
|
||||||
|
case "Courier-Bold":
|
||||||
|
return fonts.monoBold;
|
||||||
|
case "Courier":
|
||||||
|
return fonts.mono;
|
||||||
|
default:
|
||||||
|
return fontFamily;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapStyleFontFamilies<T extends Styles>(
|
||||||
|
sheet: T,
|
||||||
|
fonts: ResolvedPdfFonts,
|
||||||
|
): T {
|
||||||
|
const remapped = {} as T;
|
||||||
|
|
||||||
|
for (const [key, style] of Object.entries(sheet)) {
|
||||||
|
const fontFamily = (style as { fontFamily?: string }).fontFamily;
|
||||||
|
remapped[key as keyof T] = {
|
||||||
|
...style,
|
||||||
|
...(fontFamily
|
||||||
|
? { fontFamily: mapLegacyPdfFont(fontFamily, fonts) }
|
||||||
|
: {}),
|
||||||
|
} as T[keyof T];
|
||||||
|
}
|
||||||
|
|
||||||
|
return remapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PdfStyleBundle = {
|
||||||
|
styles: typeof baseStyles;
|
||||||
|
minimalStyles: typeof baseMinimalStyles;
|
||||||
|
fonts: ResolvedPdfFonts;
|
||||||
|
getStatusStyle: (
|
||||||
|
status: string,
|
||||||
|
) => Array<Record<string, string | number>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfStyleCache = new Map<string, PdfStyleBundle>();
|
||||||
|
|
||||||
|
function getPdfStyleBundle(
|
||||||
|
bodyFamily: PdfFontFamily,
|
||||||
|
numericFamily: PdfFontFamily,
|
||||||
|
): PdfStyleBundle {
|
||||||
|
const cacheKey = pdfFontCacheKey(bodyFamily, numericFamily);
|
||||||
|
const cached = pdfStyleCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const fonts = resolvePdfFonts(bodyFamily, numericFamily);
|
||||||
|
const styles = remapStyleFontFamilies(baseStyles, fonts);
|
||||||
|
const bundle: PdfStyleBundle = {
|
||||||
|
styles,
|
||||||
|
minimalStyles: baseMinimalStyles,
|
||||||
|
fonts,
|
||||||
|
getStatusStyle: (status: string) => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "paid":
|
||||||
|
return [styles.statusBadge, styles.statusPaid];
|
||||||
|
case "sent":
|
||||||
|
return [styles.statusBadge, styles.statusPaid];
|
||||||
|
case "overdue":
|
||||||
|
return [
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: "#fef2f2", color: "#dc2626" },
|
||||||
|
];
|
||||||
|
case "draft":
|
||||||
|
return [
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [styles.statusBadge, styles.statusUnpaid];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pdfStyleCache.set(cacheKey, bundle);
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseStyles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
@@ -537,7 +639,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const minimalStyles = StyleSheet.create({
|
const baseMinimalStyles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
paddingTop: 28,
|
paddingTop: 28,
|
||||||
@@ -729,27 +831,6 @@ const getStatusLabel = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
|
||||||
switch (status.toLowerCase()) {
|
|
||||||
case "paid":
|
|
||||||
return [styles.statusBadge, styles.statusPaid];
|
|
||||||
case "sent":
|
|
||||||
return [styles.statusBadge, styles.statusPaid];
|
|
||||||
case "overdue":
|
|
||||||
return [
|
|
||||||
styles.statusBadge,
|
|
||||||
{ backgroundColor: "#fef2f2", color: "#dc2626" },
|
|
||||||
];
|
|
||||||
case "draft":
|
|
||||||
return [
|
|
||||||
styles.statusBadge,
|
|
||||||
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [styles.statusBadge, styles.statusUnpaid];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getColumnWidths(showRate: boolean) {
|
function getColumnWidths(showRate: boolean) {
|
||||||
return showRate
|
return showRate
|
||||||
? {
|
? {
|
||||||
@@ -766,7 +847,9 @@ function getColumnWidths(showRate: boolean) {
|
|||||||
const DenseHeader: React.FC<{
|
const DenseHeader: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
}> = ({ invoice, settings }) => {
|
pdfStyles: PdfStyleBundle;
|
||||||
|
}> = ({ invoice, settings, pdfStyles }) => {
|
||||||
|
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
|
||||||
const isMinimal = settings.pdfTemplate === "minimal";
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1029,7 +1112,9 @@ const DenseHeader: React.FC<{
|
|||||||
const TableHeader: React.FC<{
|
const TableHeader: React.FC<{
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
showRate: boolean;
|
showRate: boolean;
|
||||||
}> = ({ settings, showRate }) => {
|
pdfStyles: PdfStyleBundle;
|
||||||
|
}> = ({ settings, showRate, pdfStyles }) => {
|
||||||
|
const { styles, minimalStyles } = pdfStyles;
|
||||||
const cols = getColumnWidths(showRate);
|
const cols = getColumnWidths(showRate);
|
||||||
const isMinimal = settings.pdfTemplate === "minimal";
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
return (
|
return (
|
||||||
@@ -1094,7 +1179,9 @@ const TableHeader: React.FC<{
|
|||||||
const NotesSection: React.FC<{
|
const NotesSection: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
}> = ({ invoice, settings }) => {
|
pdfStyles: PdfStyleBundle;
|
||||||
|
}> = ({ invoice, settings, pdfStyles }) => {
|
||||||
|
const { styles, minimalStyles } = pdfStyles;
|
||||||
if (!invoice.notes) return null;
|
if (!invoice.notes) return null;
|
||||||
const isMinimal = settings.pdfTemplate === "minimal";
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
|
||||||
@@ -1129,9 +1216,11 @@ const NotesSection: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
const Footer: React.FC<{
|
||||||
settings,
|
settings: Required<PDFGenerationSettings>;
|
||||||
}) => {
|
pdfStyles: PdfStyleBundle;
|
||||||
|
}> = ({ settings, pdfStyles }) => {
|
||||||
|
const { styles, minimalStyles, fonts } = pdfStyles;
|
||||||
const isMinimal = settings.pdfTemplate === "minimal";
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1151,7 +1240,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: isMinimal ? 8 : 9,
|
fontSize: isMinimal ? 8 : 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: fonts.regular,
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
@@ -1176,7 +1265,9 @@ const TotalsSection: React.FC<{
|
|||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
}> = ({ invoice, items, settings }) => {
|
pdfStyles: PdfStyleBundle;
|
||||||
|
}> = ({ invoice, items, settings, pdfStyles }) => {
|
||||||
|
const { styles, minimalStyles, fonts } = pdfStyles;
|
||||||
const currency = invoice.currency ?? "USD";
|
const currency = invoice.currency ?? "USD";
|
||||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
@@ -1206,7 +1297,7 @@ const TotalsSection: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: isMinimal ? 8 : 11,
|
fontSize: isMinimal ? 8 : 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: fonts.bold,
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
textAlign: isMinimal ? "left" : "center",
|
textAlign: isMinimal ? "left" : "center",
|
||||||
marginBottom: isMinimal ? 5 : 8,
|
marginBottom: isMinimal ? 5 : 8,
|
||||||
@@ -1301,6 +1392,26 @@ export const InvoicePDF: React.FC<{
|
|||||||
settings?: PDFGenerationSettings;
|
settings?: PDFGenerationSettings;
|
||||||
}> = ({ invoice, settings: inputSettings }) => {
|
}> = ({ invoice, settings: inputSettings }) => {
|
||||||
const settings = resolvePDFSettings(inputSettings);
|
const settings = resolvePDFSettings(inputSettings);
|
||||||
|
const pdfStyles = getPdfStyleBundle(
|
||||||
|
settings.pdfFontFamily,
|
||||||
|
settings.pdfNumericFontFamily,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InvoicePDFDocument
|
||||||
|
invoice={invoice}
|
||||||
|
settings={settings}
|
||||||
|
pdfStyles={pdfStyles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InvoicePDFDocument: React.FC<{
|
||||||
|
invoice: InvoiceData;
|
||||||
|
settings: Required<PDFGenerationSettings>;
|
||||||
|
pdfStyles: PdfStyleBundle;
|
||||||
|
}> = ({ invoice, settings, pdfStyles }) => {
|
||||||
|
const { styles, minimalStyles } = pdfStyles;
|
||||||
const items = invoice.items?.filter(Boolean) ?? [];
|
const items = invoice.items?.filter(Boolean) ?? [];
|
||||||
const currency = invoice.currency ?? "USD";
|
const currency = invoice.currency ?? "USD";
|
||||||
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||||
@@ -1313,7 +1424,11 @@ export const InvoicePDF: React.FC<{
|
|||||||
size="LETTER"
|
size="LETTER"
|
||||||
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
|
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
|
||||||
>
|
>
|
||||||
<DenseHeader invoice={invoice} settings={settings} />
|
<DenseHeader
|
||||||
|
invoice={invoice}
|
||||||
|
settings={settings}
|
||||||
|
pdfStyles={pdfStyles}
|
||||||
|
/>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<View
|
<View
|
||||||
@@ -1322,7 +1437,11 @@ export const InvoicePDF: React.FC<{
|
|||||||
isMinimal ? minimalStyles.tableContainer : {},
|
isMinimal ? minimalStyles.tableContainer : {},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TableHeader settings={settings} showRate={showRate} />
|
<TableHeader
|
||||||
|
settings={settings}
|
||||||
|
showRate={showRate}
|
||||||
|
pdfStyles={pdfStyles}
|
||||||
|
/>
|
||||||
{items.map(
|
{items.map(
|
||||||
(item, index) =>
|
(item, index) =>
|
||||||
item && (
|
item && (
|
||||||
@@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
{ width: cols.hours },
|
{ width: cols.hours },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{item.hours}
|
{isFixedLineItem(item.hours) ? "—" : item.hours}
|
||||||
</Text>
|
</Text>
|
||||||
{showRate && (
|
{showRate && (
|
||||||
<Text
|
<Text
|
||||||
@@ -1404,12 +1523,21 @@ export const InvoicePDF: React.FC<{
|
|||||||
wrap={false}
|
wrap={false}
|
||||||
>
|
>
|
||||||
{invoice.notes && (
|
{invoice.notes && (
|
||||||
<NotesSection invoice={invoice} settings={settings} />
|
<NotesSection
|
||||||
|
invoice={invoice}
|
||||||
|
settings={settings}
|
||||||
|
pdfStyles={pdfStyles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<TotalsSection invoice={invoice} items={items} settings={settings} />
|
<TotalsSection
|
||||||
|
invoice={invoice}
|
||||||
|
items={items}
|
||||||
|
settings={settings}
|
||||||
|
pdfStyles={pdfStyles}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Footer settings={settings} />
|
<Footer settings={settings} pdfStyles={pdfStyles} />
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Built-in PDF font presets (react-pdf standard fonts, no embedding required). */
|
||||||
|
export const pdfFontFamilyValues = ["sans", "serif", "mono"] as const;
|
||||||
|
|
||||||
|
export const pdfFontFamilySchema = z.enum(pdfFontFamilyValues);
|
||||||
|
|
||||||
|
export type PdfFontFamily = z.infer<typeof pdfFontFamilySchema>;
|
||||||
|
|
||||||
|
export interface ResolvedPdfFonts {
|
||||||
|
regular: string;
|
||||||
|
bold: string;
|
||||||
|
mono: string;
|
||||||
|
monoBold: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pdfFontFamilyOptions: {
|
||||||
|
value: PdfFontFamily;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "sans",
|
||||||
|
label: "Modern",
|
||||||
|
description: "Clean sans-serif (Helvetica).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "serif",
|
||||||
|
label: "Classic",
|
||||||
|
description: "Traditional serif (Times).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "mono",
|
||||||
|
label: "Monospace",
|
||||||
|
description: "Fixed-width type (Courier).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveBodyFonts(family: PdfFontFamily): Pick<ResolvedPdfFonts, "regular" | "bold"> {
|
||||||
|
switch (family) {
|
||||||
|
case "serif":
|
||||||
|
return {
|
||||||
|
regular: "Times-Roman",
|
||||||
|
bold: "Times-Bold",
|
||||||
|
};
|
||||||
|
case "mono":
|
||||||
|
return {
|
||||||
|
regular: "Courier",
|
||||||
|
bold: "Courier-Bold",
|
||||||
|
};
|
||||||
|
case "sans":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
regular: "Helvetica",
|
||||||
|
bold: "Helvetica-Bold",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNumericFonts(
|
||||||
|
family: PdfFontFamily,
|
||||||
|
): Pick<ResolvedPdfFonts, "mono" | "monoBold"> {
|
||||||
|
switch (family) {
|
||||||
|
case "serif":
|
||||||
|
return {
|
||||||
|
mono: "Times-Roman",
|
||||||
|
monoBold: "Times-Bold",
|
||||||
|
};
|
||||||
|
case "mono":
|
||||||
|
return {
|
||||||
|
mono: "Courier",
|
||||||
|
monoBold: "Courier-Bold",
|
||||||
|
};
|
||||||
|
case "sans":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
mono: "Helvetica",
|
||||||
|
monoBold: "Helvetica-Bold",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePdfFonts(
|
||||||
|
bodyFamily: PdfFontFamily,
|
||||||
|
numericFamily: PdfFontFamily = "mono",
|
||||||
|
): ResolvedPdfFonts {
|
||||||
|
return {
|
||||||
|
...resolveBodyFonts(bodyFamily),
|
||||||
|
...resolveNumericFonts(numericFamily),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfFontCacheKey(
|
||||||
|
bodyFamily: PdfFontFamily,
|
||||||
|
numericFamily: PdfFontFamily,
|
||||||
|
): string {
|
||||||
|
return `${bodyFamily}:${numericFamily}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfFontFamily(value: unknown): value is PdfFontFamily {
|
||||||
|
return pdfFontFamilySchema.safeParse(value).success;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export function invoiceLabel(inv: {
|
||||||
|
invoicePrefix: string | null;
|
||||||
|
invoiceNumber: string;
|
||||||
|
}) {
|
||||||
|
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryHref(entry: {
|
||||||
|
invoiceId: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
invoice?: { id: string } | null;
|
||||||
|
client?: { id: string } | null;
|
||||||
|
}): string | null {
|
||||||
|
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
|
||||||
|
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
|
||||||
|
const clientId = entry.clientId ?? entry.client?.id;
|
||||||
|
if (clientId) return `/dashboard/clients/${clientId}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeEntryListItem = {
|
||||||
|
id: string;
|
||||||
|
description: string | null;
|
||||||
|
hours: number | null;
|
||||||
|
rate: number | null;
|
||||||
|
startedAt: Date;
|
||||||
|
endedAt: Date | null;
|
||||||
|
clientId: string | null;
|
||||||
|
invoiceId: string | null;
|
||||||
|
client?: { id: string; name: string } | null;
|
||||||
|
invoice?: {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function groupEntriesByDate<T extends { startedAt: Date }>(
|
||||||
|
entries: T[],
|
||||||
|
): { dateKey: string; label: string; entries: T[] }[] {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const d = new Date(entry.startedAt);
|
||||||
|
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
const existing = groups.get(dateKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(entry);
|
||||||
|
} else {
|
||||||
|
groups.set(dateKey, [entry]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([dateKey, groupEntries]) => {
|
||||||
|
const sample = new Date(groupEntries[0]!.startedAt);
|
||||||
|
const label = sample.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
return { dateKey, label, entries: groupEntries };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type { db as Db } from "~/server/db";
|
||||||
|
import { businesses } from "~/server/db/schema";
|
||||||
|
|
||||||
|
type BusinessContext = {
|
||||||
|
db: typeof Db;
|
||||||
|
session: { user: { id: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function verifyBusinessAccess(
|
||||||
|
ctx: BusinessContext,
|
||||||
|
businessId?: string | null,
|
||||||
|
) {
|
||||||
|
if (!businessId) return null;
|
||||||
|
|
||||||
|
const business = await ctx.db.query.businesses.findFirst({
|
||||||
|
where: eq(businesses.id, businessId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Business not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (business.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to use this business",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveDefaultBusiness(ctx: BusinessContext) {
|
||||||
|
const [defaultBusiness] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(businesses)
|
||||||
|
.where(eq(businesses.createdById, ctx.session.user.id))
|
||||||
|
.orderBy(desc(businesses.isDefault), desc(businesses.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return defaultBusiness ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve explicit businessId or fall back to the user's default business. */
|
||||||
|
export async function resolveBusinessForExpense(
|
||||||
|
ctx: BusinessContext,
|
||||||
|
businessId?: string | null,
|
||||||
|
) {
|
||||||
|
if (businessId && businessId.trim() !== "") {
|
||||||
|
return verifyBusinessAccess(ctx, businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveDefaultBusiness(ctx);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
import type { db as database } from "~/server/db";
|
||||||
|
|
||||||
|
export async function requireAdmin(ctx: {
|
||||||
|
db: typeof database;
|
||||||
|
session: { user: { id: string } };
|
||||||
|
}) {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
columns: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { paymentsRouter } from "~/server/api/routers/payments";
|
|||||||
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
|
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
|
||||||
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
||||||
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
||||||
|
import { adminRouter } from "~/server/api/routers/admin";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
recurringInvoices: recurringInvoicesRouter,
|
recurringInvoices: recurringInvoicesRouter,
|
||||||
apiKeys: apiKeysRouter,
|
apiKeys: apiKeysRouter,
|
||||||
timeEntries: timeEntriesRouter,
|
timeEntries: timeEntriesRouter,
|
||||||
|
admin: adminRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { and, count, desc, eq, gte, ilike, ne, or, sql } from "drizzle-orm";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { logAuditEvent } from "~/lib/audit-log";
|
||||||
|
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
import { requireAdmin } from "~/server/api/require-admin";
|
||||||
|
import {
|
||||||
|
auditLog,
|
||||||
|
businesses,
|
||||||
|
clients,
|
||||||
|
invoices,
|
||||||
|
sessions,
|
||||||
|
timeEntries,
|
||||||
|
users,
|
||||||
|
} from "~/server/db/schema";
|
||||||
|
|
||||||
|
const ACTIVE_USER_DAYS = 30;
|
||||||
|
|
||||||
|
async function assertNotLastAdmin(
|
||||||
|
db: Parameters<typeof requireAdmin>[0]["db"],
|
||||||
|
userId: string,
|
||||||
|
newRole: "user" | "admin",
|
||||||
|
) {
|
||||||
|
if (newRole === "admin") return;
|
||||||
|
|
||||||
|
const target = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (target?.role !== "admin") return;
|
||||||
|
|
||||||
|
const [adminCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.role, "admin"));
|
||||||
|
|
||||||
|
if ((adminCount?.count ?? 0) <= 1) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot remove the last administrator",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminRouter = createTRPCRouter({
|
||||||
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
const activeSince = new Date();
|
||||||
|
activeSince.setDate(activeSince.getDate() - ACTIVE_USER_DAYS);
|
||||||
|
|
||||||
|
const [
|
||||||
|
[totalUsersRow],
|
||||||
|
[activeUsersRow],
|
||||||
|
[totalInvoicesRow],
|
||||||
|
[totalBusinessesRow],
|
||||||
|
[totalClientsRow],
|
||||||
|
[totalTimeEntriesRow],
|
||||||
|
[adminCountRow],
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.db.select({ count: count() }).from(users),
|
||||||
|
ctx.db
|
||||||
|
.select({ count: sql<number>`count(distinct ${sessions.userId})::int` })
|
||||||
|
.from(sessions)
|
||||||
|
.where(gte(sessions.updatedAt, activeSince)),
|
||||||
|
ctx.db.select({ count: count() }).from(invoices),
|
||||||
|
ctx.db.select({ count: count() }).from(businesses),
|
||||||
|
ctx.db.select({ count: count() }).from(clients),
|
||||||
|
ctx.db.select({ count: count() }).from(timeEntries),
|
||||||
|
ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.role, "admin")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: totalUsersRow?.count ?? 0,
|
||||||
|
activeUsers: activeUsersRow?.count ?? 0,
|
||||||
|
totalInvoices: totalInvoicesRow?.count ?? 0,
|
||||||
|
totalBusinesses: totalBusinessesRow?.count ?? 0,
|
||||||
|
totalClients: totalClientsRow?.count ?? 0,
|
||||||
|
totalTimeEntries: totalTimeEntriesRow?.count ?? 0,
|
||||||
|
adminCount: adminCountRow?.count ?? 0,
|
||||||
|
activeUserWindowDays: ACTIVE_USER_DAYS,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
listUsers: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
limit: z.number().int().min(1).max(100).default(25),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
const search = input.search?.trim();
|
||||||
|
const whereClause = search
|
||||||
|
? or(
|
||||||
|
ilike(users.name, `%${search}%`),
|
||||||
|
ilike(users.email, `%${search}%`),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [items, [totalRow]] = await Promise.all([
|
||||||
|
ctx.db.query.users.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: (usersTable, { asc }) => [asc(usersTable.createdAt)],
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(users)
|
||||||
|
.where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: totalRow?.count ?? 0,
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateUser: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
email: z.string().email("Invalid email"),
|
||||||
|
role: z.enum(["user", "admin"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
const existing = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, input.userId),
|
||||||
|
columns: { id: true, name: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = input.email.toLowerCase();
|
||||||
|
if (normalizedEmail !== existing.email) {
|
||||||
|
const emailTaken = await ctx.db.query.users.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(users.email, normalizedEmail),
|
||||||
|
ne(users.id, input.userId),
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailTaken) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Email is already in use",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertNotLastAdmin(ctx.db, input.userId, input.role);
|
||||||
|
|
||||||
|
const changedFields: string[] = [];
|
||||||
|
if (existing.name !== input.name) changedFields.push("name");
|
||||||
|
if (existing.email !== normalizedEmail) changedFields.push("email");
|
||||||
|
if (existing.role !== input.role) changedFields.push("role");
|
||||||
|
|
||||||
|
if (changedFields.length === 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
email: normalizedEmail,
|
||||||
|
role: input.role,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action:
|
||||||
|
changedFields.includes("role") && changedFields.length === 1
|
||||||
|
? "user.role_updated"
|
||||||
|
: "user.profile_updated",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: input.userId,
|
||||||
|
metadata: {
|
||||||
|
changedFields,
|
||||||
|
...(changedFields.includes("role") && {
|
||||||
|
previousRole: existing.role,
|
||||||
|
newRole: input.role,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendPasswordReset: protectedProcedure
|
||||||
|
.input(z.object({ userId: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, input.userId),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendPasswordResetForUser(input.userId);
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "user.password_reset_sent",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: input.userId,
|
||||||
|
metadata: { emailSent: result.emailSent },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
emailSent: result.emailSent,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
listAuditLog: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
limit: z.number().int().min(1).max(100).default(25),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
const [entries, [totalRow]] = await Promise.all([
|
||||||
|
ctx.db.query.auditLog.findMany({
|
||||||
|
orderBy: [desc(auditLog.createdAt)],
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit,
|
||||||
|
with: {
|
||||||
|
actor: {
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.select({ count: count() }).from(auditLog),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
action: entry.action,
|
||||||
|
targetType: entry.targetType,
|
||||||
|
targetId: entry.targetId,
|
||||||
|
metadata: entry.metadata,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
actor: entry.actor,
|
||||||
|
})),
|
||||||
|
total: totalRow?.count ?? 0,
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -94,6 +94,16 @@ export const emailRouter = createTRPCRouter({
|
|||||||
| "minimal"
|
| "minimal"
|
||||||
| undefined,
|
| undefined,
|
||||||
pdfAccentColor: settings?.pdfAccentColor,
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
|
pdfFontFamily: settings?.pdfFontFamily as
|
||||||
|
| "sans"
|
||||||
|
| "serif"
|
||||||
|
| "mono"
|
||||||
|
| undefined,
|
||||||
|
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||||
|
| "sans"
|
||||||
|
| "serif"
|
||||||
|
| "mono"
|
||||||
|
| undefined,
|
||||||
pdfFooterText: settings?.pdfFooterText,
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
pdfShowLogo: settings?.pdfShowLogo,
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
import {
|
||||||
|
expenses,
|
||||||
|
clients,
|
||||||
|
invoices,
|
||||||
|
expenseReceipts,
|
||||||
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
import {
|
||||||
|
resolveBusinessForExpense,
|
||||||
|
verifyBusinessAccess,
|
||||||
|
} from "~/server/api/lib/business-access";
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
isAllowedReceiptMime,
|
||||||
|
putObject,
|
||||||
|
RECEIPT_MAX_BYTES,
|
||||||
|
} from "~/lib/object-storage";
|
||||||
|
|
||||||
export { EXPENSE_CATEGORIES };
|
export { EXPENSE_CATEGORIES };
|
||||||
|
|
||||||
@@ -26,11 +41,105 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function verifyClientAccess(
|
||||||
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||||
|
clientId: string,
|
||||||
|
) {
|
||||||
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!client) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Client not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyInvoiceAccess(
|
||||||
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||||
|
invoiceId: string,
|
||||||
|
) {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.id, invoiceId),
|
||||||
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!invoice) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Invoice not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveExpenseBusinessId(
|
||||||
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||||
|
businessId: string | null,
|
||||||
|
invoice?: { businessId: string | null } | null,
|
||||||
|
) {
|
||||||
|
const explicitBusinessId =
|
||||||
|
businessId && businessId.trim() !== "" ? businessId : null;
|
||||||
|
const inheritedBusinessId =
|
||||||
|
!explicitBusinessId && invoice?.businessId ? invoice.businessId : null;
|
||||||
|
|
||||||
|
const resolved = await resolveBusinessForExpense(
|
||||||
|
ctx,
|
||||||
|
explicitBusinessId ?? inheritedBusinessId,
|
||||||
|
);
|
||||||
|
return resolved?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOwnedExpense(
|
||||||
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||||
|
expenseId: string,
|
||||||
|
) {
|
||||||
|
const expense = await ctx.db.query.expenses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(expenses.id, expenseId),
|
||||||
|
eq(expenses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!expense) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Expense not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return expense;
|
||||||
|
}
|
||||||
|
|
||||||
export const expensesRouter = createTRPCRouter({
|
export const expensesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
businessId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const conditions = [eq(expenses.createdById, ctx.session.user.id)];
|
||||||
|
|
||||||
|
if (input?.businessId) {
|
||||||
|
await verifyBusinessAccess(ctx, input.businessId);
|
||||||
|
conditions.push(eq(expenses.businessId, input.businessId));
|
||||||
|
}
|
||||||
|
|
||||||
return await ctx.db.query.expenses.findMany({
|
return await ctx.db.query.expenses.findMany({
|
||||||
where: eq(expenses.createdById, ctx.session.user.id),
|
where: and(...conditions),
|
||||||
with: { client: true, business: true, invoice: true },
|
with: {
|
||||||
|
client: true,
|
||||||
|
business: true,
|
||||||
|
invoice: true,
|
||||||
|
receipts: true,
|
||||||
|
},
|
||||||
orderBy: [desc(expenses.date)],
|
orderBy: [desc(expenses.date)],
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -43,7 +152,12 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
eq(expenses.id, input.id),
|
eq(expenses.id, input.id),
|
||||||
eq(expenses.createdById, ctx.session.user.id),
|
eq(expenses.createdById, ctx.session.user.id),
|
||||||
),
|
),
|
||||||
with: { client: true, business: true, invoice: true },
|
with: {
|
||||||
|
client: true,
|
||||||
|
business: true,
|
||||||
|
invoice: true,
|
||||||
|
receipts: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!expense) {
|
if (!expense) {
|
||||||
@@ -69,50 +183,26 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (clean.clientId) {
|
if (clean.clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
await verifyClientAccess(ctx, clean.clientId);
|
||||||
where: and(
|
|
||||||
eq(clients.id, clean.clientId),
|
|
||||||
eq(clients.createdById, ctx.session.user.id),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!client)
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Client not found",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clean.businessId) {
|
const invoice = clean.invoiceId
|
||||||
const business = await ctx.db.query.businesses.findFirst({
|
? await verifyInvoiceAccess(ctx, clean.invoiceId)
|
||||||
where: and(
|
: null;
|
||||||
eq(businesses.id, clean.businessId),
|
|
||||||
eq(businesses.createdById, ctx.session.user.id),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!business)
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Business not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clean.invoiceId) {
|
const businessId = await resolveExpenseBusinessId(
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
ctx,
|
||||||
where: and(
|
clean.businessId,
|
||||||
eq(invoices.id, clean.invoiceId),
|
invoice,
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
);
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!invoice)
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Invoice not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [expense] = await ctx.db
|
const [expense] = await ctx.db
|
||||||
.insert(expenses)
|
.insert(expenses)
|
||||||
.values({ ...clean, createdById: ctx.session.user.id })
|
.values({
|
||||||
|
...clean,
|
||||||
|
businessId,
|
||||||
|
createdById: ctx.session.user.id,
|
||||||
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return expense;
|
return expense;
|
||||||
@@ -137,17 +227,56 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const clean = {
|
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
...data,
|
|
||||||
clientId: data.clientId?.trim() ?? null,
|
|
||||||
businessId: data.businessId?.trim() ?? null,
|
|
||||||
invoiceId: data.invoiceId?.trim() ?? null,
|
|
||||||
category: data.category?.trim() ?? null,
|
|
||||||
notes: data.notes?.trim() ?? null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
|
if (data.date !== undefined) updates.date = data.date;
|
||||||
|
if (data.description !== undefined) updates.description = data.description;
|
||||||
|
if (data.amount !== undefined) updates.amount = data.amount;
|
||||||
|
if (data.currency !== undefined) updates.currency = data.currency;
|
||||||
|
if (data.billable !== undefined) updates.billable = data.billable;
|
||||||
|
if (data.reimbursable !== undefined) updates.reimbursable = data.reimbursable;
|
||||||
|
if (data.taxDeductible !== undefined) updates.taxDeductible = data.taxDeductible;
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
updates.category = data.category?.trim() ?? null;
|
||||||
|
}
|
||||||
|
if (data.notes !== undefined) {
|
||||||
|
updates.notes = data.notes?.trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextClientId =
|
||||||
|
data.clientId !== undefined ? data.clientId?.trim() || null : existing.clientId;
|
||||||
|
if (data.clientId !== undefined) {
|
||||||
|
if (nextClientId) await verifyClientAccess(ctx, nextClientId);
|
||||||
|
updates.clientId = nextClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextInvoiceId =
|
||||||
|
data.invoiceId !== undefined
|
||||||
|
? data.invoiceId?.trim() || null
|
||||||
|
: existing.invoiceId;
|
||||||
|
let invoice = null;
|
||||||
|
if (data.invoiceId !== undefined) {
|
||||||
|
invoice = nextInvoiceId
|
||||||
|
? await verifyInvoiceAccess(ctx, nextInvoiceId)
|
||||||
|
: null;
|
||||||
|
updates.invoiceId = nextInvoiceId;
|
||||||
|
} else if (nextInvoiceId) {
|
||||||
|
invoice = await verifyInvoiceAccess(ctx, nextInvoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.businessId !== undefined || data.invoiceId !== undefined) {
|
||||||
|
const nextBusinessInput =
|
||||||
|
data.businessId !== undefined
|
||||||
|
? data.businessId?.trim() || null
|
||||||
|
: existing.businessId;
|
||||||
|
updates.businessId = await resolveExpenseBusinessId(
|
||||||
|
ctx,
|
||||||
|
nextBusinessInput,
|
||||||
|
invoice,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.update(expenses).set(updates).where(eq(expenses.id, id));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
@@ -160,6 +289,7 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
eq(expenses.id, input.id),
|
eq(expenses.id, input.id),
|
||||||
eq(expenses.createdById, ctx.session.user.id),
|
eq(expenses.createdById, ctx.session.user.id),
|
||||||
),
|
),
|
||||||
|
with: { receipts: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -169,8 +299,101 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
existing.receipts.map((receipt) => deleteObject(receipt.storageKey)),
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listReceipts: protectedProcedure
|
||||||
|
.input(z.object({ expenseId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await getOwnedExpense(ctx, input.expenseId);
|
||||||
|
|
||||||
|
return ctx.db.query.expenseReceipts.findMany({
|
||||||
|
where: eq(expenseReceipts.expenseId, input.expenseId),
|
||||||
|
orderBy: [desc(expenseReceipts.createdAt)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
uploadReceipt: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
expenseId: z.string(),
|
||||||
|
filename: z.string().min(1).max(255),
|
||||||
|
mimeType: z.string().min(1).max(100),
|
||||||
|
data: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await getOwnedExpense(ctx, input.expenseId);
|
||||||
|
|
||||||
|
if (!isAllowedReceiptMime(input.mimeType)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Only images and PDF files are allowed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.from(input.data, "base64");
|
||||||
|
if (body.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "File is empty",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (body.length > RECEIPT_MAX_BYTES) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "File exceeds 10MB limit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
const storageKey = `receipts/${ctx.session.user.id}/${input.expenseId}/${crypto.randomUUID()}-${safeName}`;
|
||||||
|
|
||||||
|
await putObject(storageKey, body, input.mimeType);
|
||||||
|
|
||||||
|
const [receipt] = await ctx.db
|
||||||
|
.insert(expenseReceipts)
|
||||||
|
.values({
|
||||||
|
expenseId: input.expenseId,
|
||||||
|
storageKey,
|
||||||
|
originalFilename: input.filename,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
sizeBytes: body.length,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return receipt;
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteReceipt: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const receipt = await ctx.db.query.expenseReceipts.findFirst({
|
||||||
|
where: eq(expenseReceipts.id, input.id),
|
||||||
|
with: { expense: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!receipt ||
|
||||||
|
receipt.expense.createdById !== ctx.session.user.id
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Receipt not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteObject(receipt.storageKey);
|
||||||
|
await ctx.db
|
||||||
|
.delete(expenseReceipts)
|
||||||
|
.where(eq(expenseReceipts.id, input.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
platformSettings,
|
platformSettings,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
@@ -22,12 +23,29 @@ type InvoiceRouterContext = {
|
|||||||
session: { user: { id: string } };
|
session: { user: { id: string } };
|
||||||
};
|
};
|
||||||
|
|
||||||
const invoiceItemSchema = z.object({
|
const invoiceItemSchema = z
|
||||||
|
.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
description: z.string().min(1, "Description is required"),
|
description: z.string().min(1, "Description is required"),
|
||||||
hours: z.number().min(0, "Hours must be positive"),
|
hours: z.number().min(0, "Hours must be positive"),
|
||||||
rate: z.number().min(0, "Rate must be positive"),
|
rate: z.number().min(0, "Rate must be positive"),
|
||||||
});
|
})
|
||||||
|
.superRefine((item, ctx) => {
|
||||||
|
if (item.hours === 0 && item.rate <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Fixed line items need an amount greater than zero",
|
||||||
|
path: ["rate"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.hours > 0 && item.rate <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Hourly line items need a rate greater than zero",
|
||||||
|
path: ["rate"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||||
@@ -162,7 +180,10 @@ const calculateInvoiceTotal = (
|
|||||||
items: Array<z.infer<typeof invoiceItemSchema>>,
|
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||||
taxRate: number,
|
taxRate: number,
|
||||||
) => {
|
) => {
|
||||||
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
const subtotal = items.reduce(
|
||||||
|
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||||
|
0,
|
||||||
|
);
|
||||||
const taxAmount = (subtotal * taxRate) / 100;
|
const taxAmount = (subtotal * taxRate) / 100;
|
||||||
return subtotal + taxAmount;
|
return subtotal + taxAmount;
|
||||||
};
|
};
|
||||||
@@ -445,7 +466,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
items.map((item, idx) => ({
|
items.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
amount: item.hours * item.rate,
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
position: idx,
|
position: idx,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -563,7 +584,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
items.map((item, idx) => ({
|
items.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
amount: item.hours * item.rate,
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
position: idx,
|
position: idx,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -863,7 +884,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
dbItems.map((item, idx) => ({
|
dbItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
amount: item.hours * item.rate,
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
position: idx,
|
position: idx,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -935,7 +956,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.hours * item.rate,
|
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -944,6 +965,16 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
| "minimal"
|
| "minimal"
|
||||||
| undefined,
|
| undefined,
|
||||||
pdfAccentColor: settings?.pdfAccentColor,
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
|
pdfFontFamily: settings?.pdfFontFamily as
|
||||||
|
| "sans"
|
||||||
|
| "serif"
|
||||||
|
| "mono"
|
||||||
|
| undefined,
|
||||||
|
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||||
|
| "sans"
|
||||||
|
| "serif"
|
||||||
|
| "mono"
|
||||||
|
| undefined,
|
||||||
pdfFooterText: settings?.pdfFooterText,
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
pdfShowLogo: settings?.pdfShowLogo,
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, count, eq, isNull } from "drizzle-orm";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { logAuditEvent } from "~/lib/audit-log";
|
||||||
import {
|
import {
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
|
import { requireAdmin } from "~/server/api/require-admin";
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
users,
|
users,
|
||||||
@@ -26,24 +28,10 @@ import {
|
|||||||
colorModeSchema,
|
colorModeSchema,
|
||||||
defaultColorMode,
|
defaultColorMode,
|
||||||
defaultPdfSettings,
|
defaultPdfSettings,
|
||||||
|
pdfFontFamilySchema,
|
||||||
pdfTemplateSchema,
|
pdfTemplateSchema,
|
||||||
type ColorMode,
|
type ColorMode,
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
import type { db as database } from "~/server/db";
|
|
||||||
|
|
||||||
async function requireAdmin(ctx: {
|
|
||||||
db: typeof database;
|
|
||||||
session: { user: { id: string } };
|
|
||||||
}) {
|
|
||||||
const user = await ctx.db.query.users.findFirst({
|
|
||||||
where: eq(users.id, ctx.session.user.id),
|
|
||||||
columns: { role: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user?.role !== "admin") {
|
|
||||||
throw new TRPCError({ code: "FORBIDDEN" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBusinessId(
|
function resolveBusinessId(
|
||||||
refs: { businessName?: string; businessNickname?: string },
|
refs: { businessName?: string; businessNickname?: string },
|
||||||
@@ -237,10 +225,50 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await requireAdmin(ctx);
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
const existing = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, input.userId),
|
||||||
|
columns: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.role === input.role) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.role === "admin" && input.role === "user") {
|
||||||
|
const [adminCount] = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.role, "admin"));
|
||||||
|
|
||||||
|
if ((adminCount?.count ?? 0) <= 1) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot remove the last administrator",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ role: input.role })
|
.set({ role: input.role })
|
||||||
.where(eq(users.id, input.userId));
|
.where(eq(users.id, input.userId));
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "user.role_updated",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: input.userId,
|
||||||
|
metadata: {
|
||||||
|
previousRole: existing.role,
|
||||||
|
newRole: input.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -382,6 +410,12 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
defaultPdfSettings.pdfTemplate,
|
defaultPdfSettings.pdfTemplate,
|
||||||
pdfAccentColor:
|
pdfAccentColor:
|
||||||
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||||
|
pdfFontFamily:
|
||||||
|
(settings?.pdfFontFamily as "sans" | "serif" | "mono" | null) ??
|
||||||
|
defaultPdfSettings.pdfFontFamily,
|
||||||
|
pdfNumericFontFamily:
|
||||||
|
(settings?.pdfNumericFontFamily as "sans" | "serif" | "mono" | null) ??
|
||||||
|
defaultPdfSettings.pdfNumericFontFamily,
|
||||||
pdfFooterText:
|
pdfFooterText:
|
||||||
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||||
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||||
@@ -395,6 +429,8 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
pdfTemplate: pdfTemplateSchema.optional(),
|
pdfTemplate: pdfTemplateSchema.optional(),
|
||||||
pdfAccentColor: z.string().min(4).max(50).optional(),
|
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||||
|
pdfFontFamily: pdfFontFamilySchema.optional(),
|
||||||
|
pdfNumericFontFamily: pdfFontFamilySchema.optional(),
|
||||||
pdfFooterText: z.string().min(1).max(120).optional(),
|
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||||
pdfShowLogo: z.boolean().optional(),
|
pdfShowLogo: z.boolean().optional(),
|
||||||
pdfShowPageNumbers: z.boolean().optional(),
|
pdfShowPageNumbers: z.boolean().optional(),
|
||||||
@@ -409,6 +445,11 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
|
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
|
||||||
pdfAccentColor:
|
pdfAccentColor:
|
||||||
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||||
|
pdfFontFamily:
|
||||||
|
input.pdfFontFamily ?? defaultPdfSettings.pdfFontFamily,
|
||||||
|
pdfNumericFontFamily:
|
||||||
|
input.pdfNumericFontFamily ??
|
||||||
|
defaultPdfSettings.pdfNumericFontFamily,
|
||||||
pdfFooterText:
|
pdfFooterText:
|
||||||
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||||
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||||
@@ -422,6 +463,12 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
...(input.pdfAccentColor && {
|
...(input.pdfAccentColor && {
|
||||||
pdfAccentColor: input.pdfAccentColor,
|
pdfAccentColor: input.pdfAccentColor,
|
||||||
}),
|
}),
|
||||||
|
...(input.pdfFontFamily && {
|
||||||
|
pdfFontFamily: input.pdfFontFamily,
|
||||||
|
}),
|
||||||
|
...(input.pdfNumericFontFamily && {
|
||||||
|
pdfNumericFontFamily: input.pdfNumericFontFamily,
|
||||||
|
}),
|
||||||
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
|
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
|
||||||
...(input.pdfShowLogo !== undefined && {
|
...(input.pdfShowLogo !== undefined && {
|
||||||
pdfShowLogo: input.pdfShowLogo,
|
pdfShowLogo: input.pdfShowLogo,
|
||||||
@@ -433,10 +480,20 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "platform.pdf_settings_updated",
|
||||||
|
targetType: "platform",
|
||||||
|
targetId: "global",
|
||||||
|
metadata: {
|
||||||
|
changedFields: Object.keys(input).filter(
|
||||||
|
(key) => input[key as keyof typeof input] !== undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Update user profile
|
|
||||||
updateProfile: protectedProcedure
|
updateProfile: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
+72
-1
@@ -41,6 +41,8 @@ export const platformSettings = createTable("platform_setting", (d) => ({
|
|||||||
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
||||||
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
|
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
|
||||||
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
|
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
|
||||||
|
pdfFontFamily: d.varchar({ length: 20 }).default("sans").notNull(),
|
||||||
|
pdfNumericFontFamily: d.varchar({ length: 20 }).default("mono").notNull(),
|
||||||
pdfFooterText: d
|
pdfFooterText: d
|
||||||
.varchar({ length: 120 })
|
.varchar({ length: 120 })
|
||||||
.default("Professional Invoicing")
|
.default("Professional Invoicing")
|
||||||
@@ -66,6 +68,39 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
invoiceTemplates: many(invoiceTemplates),
|
invoiceTemplates: many(invoiceTemplates),
|
||||||
recurringInvoices: many(recurringInvoices),
|
recurringInvoices: many(recurringInvoices),
|
||||||
timeEntries: many(timeEntries),
|
timeEntries: many(timeEntries),
|
||||||
|
auditLogsAsActor: many(auditLog),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const auditLog = createTable(
|
||||||
|
"audit_log",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
actorUserId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
action: d.varchar({ length: 100 }).notNull(),
|
||||||
|
targetType: d.varchar({ length: 50 }).notNull(),
|
||||||
|
targetId: d.varchar({ length: 255 }),
|
||||||
|
metadata: d.jsonb().$type<Record<string, unknown>>(),
|
||||||
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
index("audit_log_actor_user_id_idx").on(t.actorUserId),
|
||||||
|
index("audit_log_action_idx").on(t.action),
|
||||||
|
index("audit_log_created_at_idx").on(t.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||||
|
actor: one(users, {
|
||||||
|
fields: [auditLog.actorUserId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
@@ -452,12 +487,37 @@ export const expenses = createTable(
|
|||||||
index("expense_created_by_idx").on(t.createdById),
|
index("expense_created_by_idx").on(t.createdById),
|
||||||
index("expense_client_id_idx").on(t.clientId),
|
index("expense_client_id_idx").on(t.clientId),
|
||||||
index("expense_invoice_id_idx").on(t.invoiceId),
|
index("expense_invoice_id_idx").on(t.invoiceId),
|
||||||
|
index("expense_business_id_idx").on(t.businessId),
|
||||||
index("expense_date_idx").on(t.date),
|
index("expense_date_idx").on(t.date),
|
||||||
index("expense_billable_idx").on(t.billable),
|
index("expense_billable_idx").on(t.billable),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const expensesRelations = relations(expenses, ({ one }) => ({
|
export const expenseReceipts = createTable(
|
||||||
|
"expense_receipt",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
expenseId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => expenses.id, { onDelete: "cascade" }),
|
||||||
|
storageKey: d.varchar({ length: 500 }).notNull(),
|
||||||
|
originalFilename: d.varchar({ length: 255 }).notNull(),
|
||||||
|
mimeType: d.varchar({ length: 100 }).notNull(),
|
||||||
|
sizeBytes: d.integer().notNull(),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
}),
|
||||||
|
(t) => [index("expense_receipt_expense_id_idx").on(t.expenseId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expensesRelations = relations(expenses, ({ one, many }) => ({
|
||||||
business: one(businesses, {
|
business: one(businesses, {
|
||||||
fields: [expenses.businessId],
|
fields: [expenses.businessId],
|
||||||
references: [businesses.id],
|
references: [businesses.id],
|
||||||
@@ -474,8 +534,19 @@ export const expensesRelations = relations(expenses, ({ one }) => ({
|
|||||||
fields: [expenses.createdById],
|
fields: [expenses.createdById],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
|
receipts: many(expenseReceipts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const expenseReceiptsRelations = relations(
|
||||||
|
expenseReceipts,
|
||||||
|
({ one }) => ({
|
||||||
|
expense: one(expenses, {
|
||||||
|
fields: [expenseReceipts.expenseId],
|
||||||
|
references: [expenses.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const invoiceTemplates = createTable(
|
export const invoiceTemplates = createTable(
|
||||||
"invoice_template",
|
"invoice_template",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user