add receipts support
This commit is contained in:
+23
-2
@@ -4,9 +4,10 @@
|
||||
#
|
||||
# Quick start (local dev):
|
||||
# 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 dev
|
||||
# MinIO console: http://localhost:9001 (minioadmin / minioadmin)
|
||||
#
|
||||
# Quick start (Docker app + Postgres):
|
||||
# cp .env.example .env
|
||||
@@ -63,8 +64,10 @@ NODE_ENV=development
|
||||
# Set true when connecting to local Postgres without SSL (default for compose).
|
||||
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
|
||||
MINIO_API_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# 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.
|
||||
@@ -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).
|
||||
# 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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -24,6 +24,7 @@ next-env.d.ts
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.data/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "beenvoice",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@better-auth/expo": "^1.6.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
@@ -102,6 +103,60 @@
|
||||
"packages": {
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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"
|
||||
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:
|
||||
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
|
||||
# the existing local image and will NOT include new code. Use:
|
||||
@@ -31,11 +31,20 @@ services:
|
||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
ports:
|
||||
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
@@ -54,5 +63,41 @@ services:
|
||||
retries: 10
|
||||
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:
|
||||
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,
|
||||
"tag": "0018_user_onboarding",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@better-auth/expo": "^1.6.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
# 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
|
||||
# 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 { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
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 crypto from "crypto";
|
||||
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -17,7 +12,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
@@ -26,13 +20,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await db.query.users.findFirst({
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -44,62 +36,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
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
|
||||
}
|
||||
await sendPasswordResetForUser(user.id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -42,6 +42,12 @@ export async function GET(
|
||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
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";
|
||||
|
||||
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 { 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,6 +41,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,74 +59,374 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function AdministrationContent() {
|
||||
const {
|
||||
data: accounts = [],
|
||||
refetch,
|
||||
error,
|
||||
} = api.settings.listAccounts.useQuery();
|
||||
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Account role updated");
|
||||
void refetch();
|
||||
},
|
||||
onError: (mutationError: { message: string }) => {
|
||||
toast.error(`Failed to update role: ${mutationError.message}`);
|
||||
},
|
||||
});
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"user.profile_updated": "Profile updated",
|
||||
"user.role_updated": "Role updated",
|
||||
"user.password_reset_sent": "Password reset sent",
|
||||
"platform.pdf_settings_updated": "PDF settings updated",
|
||||
};
|
||||
|
||||
function formatAction(action: string) {
|
||||
return ACTION_LABELS[action] ?? action;
|
||||
}
|
||||
|
||||
function AdminOverview() {
|
||||
const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<Card>
|
||||
<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" />
|
||||
Administration
|
||||
Platform overview
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Administrative access is required for this page.
|
||||
Aggregate counts only — no customer data, credentials, or bulk PII.
|
||||
</CardDescription>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Accounts
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
Users
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage account access and roles without opening customer data.
|
||||
Search accounts, edit profiles, and manage access.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<CardContent className="space-y-4">
|
||||
<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
|
||||
key={account.id}
|
||||
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
key={user.id}
|
||||
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<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">
|
||||
{account.email}
|
||||
{user.email}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<Select
|
||||
value={account.role}
|
||||
onValueChange={(role) =>
|
||||
updateAccountRoleMutation.mutate({
|
||||
userId: account.id,
|
||||
role: role as "user" | "admin",
|
||||
<div className="flex flex-shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -94,8 +435,187 @@ export function AdministrationContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</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>
|
||||
<DashboardPageHeader
|
||||
title="Administration"
|
||||
description="Manage account access and platform administration"
|
||||
description="Platform statistics, user management, and audit logging"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||
@@ -28,8 +28,17 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { FileUpload } from "~/components/forms/file-upload";
|
||||
import { 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 { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||
|
||||
@@ -44,6 +53,7 @@ interface ExpenseFormData {
|
||||
taxDeductible: boolean;
|
||||
notes: string;
|
||||
clientId: string;
|
||||
businessId: string;
|
||||
}
|
||||
|
||||
const defaultForm: ExpenseFormData = {
|
||||
@@ -57,24 +67,49 @@ const defaultForm: ExpenseFormData = {
|
||||
taxDeductible: false,
|
||||
notes: "",
|
||||
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() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [businessFilter, setBusinessFilter] = useState("all");
|
||||
|
||||
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: 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({
|
||||
onSuccess: () => {
|
||||
onSuccess: (expense) => {
|
||||
if (!expense) return;
|
||||
toast.success("Expense added");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setForm(defaultForm);
|
||||
setEditId(expense.id);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
@@ -96,10 +131,30 @@ export default function ExpensesPage() {
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Receipt uploaded");
|
||||
if (editId) {
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Receipt removed");
|
||||
if (editId) {
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditId(null);
|
||||
setForm(defaultForm);
|
||||
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||
@@ -115,6 +170,7 @@ export default function ExpensesPage() {
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
businessId: expense.businessId ?? defaultBusinessId,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
@@ -130,6 +186,7 @@ export default function ExpensesPage() {
|
||||
const payload = {
|
||||
...form,
|
||||
clientId: form.clientId || undefined,
|
||||
businessId: form.businessId || undefined,
|
||||
category: form.category || undefined,
|
||||
notes: form.notes || undefined,
|
||||
taxDeductible: form.taxDeductible,
|
||||
@@ -138,6 +195,36 @@ export default function ExpensesPage() {
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const handleReceiptFiles = async (files: File[]) => {
|
||||
if (!editId) {
|
||||
toast.error("Save the expense before uploading receipts");
|
||||
return;
|
||||
}
|
||||
for (const file of files) {
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
if (!base64) {
|
||||
reject(new Error("Failed to read file"));
|
||||
return;
|
||||
}
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId: editId,
|
||||
filename: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
data,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses
|
||||
.filter((e) => e.billable)
|
||||
@@ -161,6 +248,23 @@ export default function ExpensesPage() {
|
||||
</Button>
|
||||
</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}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -202,7 +306,6 @@ export default function ExpensesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Expenses list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -259,6 +362,12 @@ export default function ExpensesPage() {
|
||||
{expense.category}
|
||||
</Badge>
|
||||
)}
|
||||
{(expense.receipts?.length ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Paperclip className="mr-1 h-3 w-3" />
|
||||
{expense.receipts?.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
@@ -266,6 +375,7 @@ export default function ExpensesPage() {
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && (
|
||||
@@ -302,8 +412,16 @@ export default function ExpensesPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setEditId(null);
|
||||
setForm(defaultForm);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||
@@ -381,6 +499,31 @@ export default function ExpensesPage() {
|
||||
</Select>
|
||||
</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">
|
||||
<Label>Client (optional)</Label>
|
||||
<Select
|
||||
@@ -441,6 +584,79 @@ export default function ExpensesPage() {
|
||||
placeholder="Additional details…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editId ? (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Label>Receipts</Label>
|
||||
{receipts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{receipts.map((receipt) => {
|
||||
const isImage = receipt.mimeType.startsWith("image/");
|
||||
const url = `/api/receipts/${receipt.id}`;
|
||||
return (
|
||||
<div
|
||||
key={receipt.id}
|
||||
className="flex items-center gap-3 rounded-md border p-2"
|
||||
>
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt={receipt.originalFilename}
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
@@ -460,7 +676,6 @@ export default function ExpensesPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import {
|
||||
formatLineItemDetail,
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
<div className="sm:hidden">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||
{formatCurrency(item.rate)}/hr
|
||||
{formatDate(item.date)} ·{" "}
|
||||
{formatLineItemDetail(item.hours, item.rate, formatCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -69,9 +73,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "hours",
|
||||
header: "Hours",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{row.getValue("hours")}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hours = row.getValue<number>("hours");
|
||||
return (
|
||||
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
@@ -80,9 +87,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "rate",
|
||||
header: "Rate",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="text-right">
|
||||
{isFixedLineItem(item.hours)
|
||||
? "—"
|
||||
: `${formatCurrency(item.rate)}/hr`}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
|
||||
@@ -61,6 +61,8 @@ export function PDFDownloadButton({
|
||||
await generateInvoicePDF(pdfData, {
|
||||
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||
pdfFontFamily: pdfSettings?.pdfFontFamily,
|
||||
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
|
||||
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||
|
||||
@@ -51,10 +51,14 @@ function toNumericChartValue(value: unknown) {
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [businessFilter, setBusinessFilter] = useState("all");
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||
api.invoices.getAll.useQuery();
|
||||
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 isLoading = invoicesLoading || expensesLoading;
|
||||
@@ -62,9 +66,14 @@ export default function ReportsPage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
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)
|
||||
const overviewData = useMemo(() => {
|
||||
if (!invoices.length) return null;
|
||||
if (!filteredInvoices.length) return null;
|
||||
|
||||
const now = new Date();
|
||||
const monthMap: Record<string, number> = {};
|
||||
@@ -78,7 +87,7 @@ export default function ReportsPage() {
|
||||
let totalPending = 0;
|
||||
let totalHours = 0;
|
||||
|
||||
for (const inv of invoices) {
|
||||
for (const inv of filteredInvoices) {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -102,7 +111,7 @@ export default function ReportsPage() {
|
||||
}));
|
||||
|
||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||
for (const inv of invoices) {
|
||||
for (const inv of filteredInvoices) {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -126,7 +135,7 @@ export default function ReportsPage() {
|
||||
paid: 0,
|
||||
overdue: 0,
|
||||
};
|
||||
for (const inv of invoices) {
|
||||
for (const inv of filteredInvoices) {
|
||||
const s = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -142,13 +151,13 @@ export default function ReportsPage() {
|
||||
totalHours,
|
||||
statusCount,
|
||||
};
|
||||
}, [invoices]);
|
||||
}, [filteredInvoices]);
|
||||
|
||||
// Tax summary for selected year
|
||||
const taxData = useMemo(() => {
|
||||
const year = parseInt(taxYear);
|
||||
|
||||
const yearInvoices = invoices.filter((inv) => {
|
||||
const yearInvoices = filteredInvoices.filter((inv) => {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
@@ -224,20 +233,20 @@ export default function ReportsPage() {
|
||||
yearInvoices,
|
||||
yearExpenses,
|
||||
};
|
||||
}, [invoices, expenses, taxYear]);
|
||||
}, [filteredInvoices, expenses, taxYear]);
|
||||
|
||||
const availableYears = useMemo(() => {
|
||||
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());
|
||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}, [invoices, expenses, currentYear]);
|
||||
}, [filteredInvoices, expenses, currentYear]);
|
||||
|
||||
const avgInvoice =
|
||||
invoices.length > 0
|
||||
filteredInvoices.length > 0
|
||||
? (overviewData?.totalRevenue ?? 0) /
|
||||
(invoices.filter(
|
||||
(filteredInvoices.filter(
|
||||
(i) =>
|
||||
getEffectiveInvoiceStatus(
|
||||
i.status as StoredInvoiceStatus,
|
||||
@@ -335,6 +344,23 @@ export default function ReportsPage() {
|
||||
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">
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="overview" className="gap-1.5">
|
||||
@@ -573,7 +599,7 @@ export default function ReportsPage() {
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{
|
||||
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
||||
width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -584,7 +610,7 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{invoices.length === 0 && (
|
||||
{filteredInvoices.length === 0 && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No invoices yet.
|
||||
</p>
|
||||
|
||||
@@ -88,7 +88,8 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
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 { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
||||
|
||||
@@ -146,6 +147,7 @@ export function SettingsContent({
|
||||
|
||||
const { data: session } = useAuthSession();
|
||||
const [name, setName] = useState("");
|
||||
const [nameInitialized, setNameInitialized] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [importData, setImportData] = useState("");
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
@@ -177,6 +179,8 @@ export function SettingsContent({
|
||||
const savePdfSettings = (patch: {
|
||||
pdfTemplate?: PdfTemplate;
|
||||
pdfAccentColor?: string;
|
||||
pdfFontFamily?: PdfFontFamily;
|
||||
pdfNumericFontFamily?: PdfFontFamily;
|
||||
pdfFooterText?: string;
|
||||
pdfShowLogo?: boolean;
|
||||
pdfShowPageNumbers?: boolean;
|
||||
@@ -217,7 +221,7 @@ export function SettingsContent({
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: profile, refetch: refetchProfile } =
|
||||
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
|
||||
api.settings.getProfile.useQuery();
|
||||
const isAdmin = profile?.role === "admin";
|
||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||
@@ -405,16 +409,13 @@ export function SettingsContent({
|
||||
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
|
||||
};
|
||||
|
||||
// Set initial name value when profile loads
|
||||
// Set initial name value once when profile loads
|
||||
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.
|
||||
setName(profile.name);
|
||||
}
|
||||
if (session?.user) {
|
||||
setName(session.user.name ?? "");
|
||||
}
|
||||
}, [session, profile?.name, name]);
|
||||
setName(profile?.name ?? session?.user?.name ?? "");
|
||||
setNameInitialized(true);
|
||||
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
|
||||
|
||||
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
||||
|
||||
@@ -790,6 +791,73 @@ export function SettingsContent({
|
||||
className="mt-0"
|
||||
/>
|
||||
</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 className="space-y-2">
|
||||
@@ -847,6 +915,9 @@ export function SettingsContent({
|
||||
settings={{
|
||||
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
|
||||
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
|
||||
pdfFontFamily: pdfSettings?.pdfFontFamily ?? "sans",
|
||||
pdfNumericFontFamily:
|
||||
pdfSettings?.pdfNumericFontFamily ?? "mono",
|
||||
pdfFooterText:
|
||||
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
|
||||
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 { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function formatDate(date: Date) {
|
||||
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||
<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>
|
||||
</div>
|
||||
<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 [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Fetch business data if editing
|
||||
const { data: business, isLoading: isLoadingBusiness } =
|
||||
api.businesses.getById.useQuery(
|
||||
{ id: businessId! },
|
||||
{ enabled: mode === "edit" && !!businessId },
|
||||
{
|
||||
enabled: mode === "edit" && !!businessId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch email configuration if editing
|
||||
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
||||
api.businesses.getEmailConfig.useQuery(
|
||||
{ id: businessId! },
|
||||
{ enabled: mode === "edit" && !!businessId },
|
||||
{
|
||||
enabled: mode === "edit" && !!businessId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Update email configuration mutation
|
||||
@@ -142,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Load business data when editing
|
||||
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.
|
||||
setFormData({
|
||||
name: business.name,
|
||||
@@ -164,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
resendDomain: emailConfig?.resendDomain ?? "",
|
||||
emailFromName: emailConfig?.emailFromName ?? "",
|
||||
});
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [business, emailConfig, mode]);
|
||||
}, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
@@ -90,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Fetch client data if editing
|
||||
const { data: client, isLoading: isLoadingClient } =
|
||||
api.clients.getById.useQuery(
|
||||
{ id: clientId! },
|
||||
{ enabled: mode === "edit" && !!clientId },
|
||||
{
|
||||
enabled: mode === "edit" && !!clientId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const createClient = api.clients.create.useMutation({
|
||||
@@ -118,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Load client data when editing
|
||||
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.
|
||||
setFormData({
|
||||
name: client.name,
|
||||
@@ -135,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||
currency: client.currency ?? "USD",
|
||||
});
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [client, mode]);
|
||||
}, [client, mode, initialized]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | number | null) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
subject: string;
|
||||
@@ -54,7 +55,7 @@ export function EmailPreview({
|
||||
const calculateTotal = () => {
|
||||
if (!invoice?.items) return 0;
|
||||
const subtotal = invoice.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
@@ -83,7 +84,7 @@ export function EmailPreview({
|
||||
description: item.description ?? "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount ?? item.hours * item.rate,
|
||||
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||
})) ?? [],
|
||||
},
|
||||
customContent: content,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
|
||||
Total
|
||||
</span>
|
||||
<span className="text-primary text-lg font-bold">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,11 @@ import {
|
||||
import { STATUS_OPTIONS } from "./invoice/types";
|
||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||
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 { CountUp } from "~/components/ui/count-up";
|
||||
@@ -123,6 +128,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -180,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
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.
|
||||
setFormData({
|
||||
@@ -206,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -238,7 +246,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
|
||||
const totals = React.useMemo(() => {
|
||||
const subtotal = formData.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||
@@ -268,6 +276,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: item.description || "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
}),
|
||||
[formData],
|
||||
@@ -298,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
hours: 1,
|
||||
rate: prev.defaultHourlyRate ?? 0,
|
||||
amount: prev.defaultHourlyRate ?? 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -313,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: parsed.description,
|
||||
hours: parsed.hours ?? 1,
|
||||
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) => ({
|
||||
...prev,
|
||||
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 };
|
||||
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 item;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
@@ -435,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
amount: i.hours * i.rate,
|
||||
amount: calculateLineItemAmount(i.hours, i.rate),
|
||||
})),
|
||||
};
|
||||
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="font-mono text-xl font-semibold">
|
||||
<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"
|
||||
/>
|
||||
</span>
|
||||
@@ -898,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
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 { NumberInput } from "~/components/ui/number-input";
|
||||
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 {
|
||||
useLineItemSuggestions,
|
||||
type LineItemSuggestion,
|
||||
} from "~/hooks/use-line-item-suggestions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -23,6 +35,7 @@ interface InvoiceItem {
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
billingType?: LineItemBillingType;
|
||||
}
|
||||
|
||||
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>(
|
||||
({ 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 (
|
||||
<div
|
||||
ref={ref}
|
||||
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
|
||||
@@ -177,6 +198,25 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
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
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
@@ -187,6 +227,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
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">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
${lineTotal.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{!readOnly ? (
|
||||
@@ -235,6 +276,10 @@ function MobileLineItem({
|
||||
onDescriptionChange,
|
||||
readOnly,
|
||||
}: LineItemRowProps) {
|
||||
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||
const isFixed = billingType === "fixed";
|
||||
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`invoice-item-${index}-mobile`}
|
||||
@@ -264,6 +309,22 @@ function MobileLineItem({
|
||||
inputClassName="h-8 px-2 text-xs"
|
||||
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
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
@@ -274,6 +335,7 @@ function MobileLineItem({
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : null}
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
@@ -285,7 +347,7 @@ function MobileLineItem({
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
${lineTotal.toFixed(2)}
|
||||
</span>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
|
||||
onUpdateItem(index, "description", s.description);
|
||||
onUpdateItem(index, "hours", s.hours);
|
||||
onUpdateItem(index, "rate", s.rate);
|
||||
onUpdateItem(index, "billingType", "hourly");
|
||||
setSuggestions([]);
|
||||
setQueriedIndex(null);
|
||||
}
|
||||
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
<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>Description</span>
|
||||
<span className="text-center">Type</span>
|
||||
<span className="text-center">Hours</span>
|
||||
<span className="text-center">Rate</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 BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
||||
|
||||
import type { LineItemBillingType } from "~/lib/invoice-line-item";
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
@@ -10,6 +12,7 @@ export interface InvoiceItem {
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
billingType: LineItemBillingType;
|
||||
}
|
||||
|
||||
export interface InvoiceFormData {
|
||||
|
||||
@@ -30,6 +30,8 @@ const SPECIAL_SEGMENTS: Record<string, string> = {
|
||||
import: "Import",
|
||||
export: "Export",
|
||||
dashboard: "Dashboard",
|
||||
entries: "All entries",
|
||||
"time-clock": "Time clock",
|
||||
};
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
resolveEffectiveHourlyRate,
|
||||
startedAtFromMinutesAgo,
|
||||
} 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;
|
||||
|
||||
@@ -46,26 +48,6 @@ export type TimeClockPanelProps = {
|
||||
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({
|
||||
label,
|
||||
active,
|
||||
@@ -145,6 +127,10 @@ export function TimeClockPanel({
|
||||
const last = getLastTimeClockClientId();
|
||||
if (last) ids.push(last);
|
||||
|
||||
if (running?.clientId && !ids.includes(running.clientId)) {
|
||||
ids.unshift(running.clientId);
|
||||
}
|
||||
|
||||
for (const entry of todayEntries ?? []) {
|
||||
if (entry.clientId && !ids.includes(entry.clientId)) {
|
||||
ids.push(entry.clientId);
|
||||
@@ -157,7 +143,7 @@ export function TimeClockPanel({
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [clients, todayEntries]);
|
||||
}, [clients, todayEntries, running]);
|
||||
|
||||
const visibleClients = useMemo(() => {
|
||||
if (!clients?.length) return [];
|
||||
@@ -191,6 +177,14 @@ export function TimeClockPanel({
|
||||
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({
|
||||
onSuccess: (data) => {
|
||||
const message = describeClockOutOutcome({
|
||||
@@ -227,6 +221,11 @@ export function TimeClockPanel({
|
||||
});
|
||||
|
||||
function handleClientChange(value: string) {
|
||||
if (running) {
|
||||
updateRunning.mutate({ clientId: value, invoiceId: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setClientId(value);
|
||||
setInvoiceId("");
|
||||
setLastTimeClockClientId(value);
|
||||
@@ -234,6 +233,15 @@ export function TimeClockPanel({
|
||||
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 {
|
||||
if (startMode === "now") return undefined;
|
||||
if (startMode === "pick") {
|
||||
@@ -293,6 +301,8 @@ export function TimeClockPanel({
|
||||
|
||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||
const activeClientId = running ? (running.clientId ?? "") : clientId;
|
||||
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
@@ -347,7 +357,7 @@ export function TimeClockPanel({
|
||||
<ClientChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={clientId === client.id}
|
||||
active={activeClientId === client.id}
|
||||
onClick={() => handleClientChange(client.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -383,7 +393,7 @@ export function TimeClockPanel({
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -485,6 +495,77 @@ export function TimeClockPanel({
|
||||
</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">
|
||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||
<Input
|
||||
@@ -498,6 +579,7 @@ export function TimeClockPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{running ? (
|
||||
@@ -529,66 +611,28 @@ export function TimeClockPanel({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!compact && todayEntries && todayEntries.length > 0 ? (
|
||||
{!compact ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<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>
|
||||
<CardContent>
|
||||
{todayEntries
|
||||
.filter((e) => e.endedAt)
|
||||
.map((entry, index, entries) => {
|
||||
const href = entryHref(entry);
|
||||
const isLast = index === entries.length - 1;
|
||||
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 (
|
||||
{todayEntries?.some((e) => e.endedAt) ? (
|
||||
<TimeEntryList entries={todayEntries} />
|
||||
) : (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No entries today.{" "}
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={href}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
|
||||
)}
|
||||
href="/dashboard/time-clock/entries"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{content}
|
||||
View history
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={rowClassName}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : 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(),
|
||||
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
|
||||
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)
|
||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||
@@ -76,6 +82,11 @@ export const env = createEnv({
|
||||
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
||||
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
|
||||
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_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
pdfFontFamilySchema,
|
||||
type PdfFontFamily,
|
||||
} from "~/lib/pdf-fonts";
|
||||
|
||||
export const colorModeValues = ["light", "dark", "system"] 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 pdfTemplateSchema = z.enum(pdfTemplateValues);
|
||||
|
||||
export { pdfFontFamilySchema, type PdfFontFamily };
|
||||
|
||||
export type ColorMode = z.infer<typeof colorModeSchema>;
|
||||
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
||||
|
||||
@@ -14,6 +20,8 @@ export const defaultColorMode: ColorMode = "system";
|
||||
export const defaultPdfSettings = {
|
||||
pdfTemplate: "classic" as PdfTemplate,
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFontFamily: "sans" as PdfFontFamily,
|
||||
pdfNumericFontFamily: "mono" as PdfFontFamily,
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: 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 { type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
||||
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||
export {
|
||||
colorModeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfFontFamilySchema,
|
||||
pdfTemplateSchema,
|
||||
} 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")
|
||||
);
|
||||
}
|
||||
if (href === "/dashboard/time-clock") {
|
||||
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
|
||||
}
|
||||
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,
|
||||
StyleSheet,
|
||||
pdf,
|
||||
type Styles,
|
||||
} from "@react-pdf/renderer";
|
||||
import { saveAs } from "file-saver";
|
||||
import {
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
import React from "react";
|
||||
import {
|
||||
type PdfFontFamily,
|
||||
type ResolvedPdfFonts,
|
||||
pdfFontCacheKey,
|
||||
resolvePdfFonts,
|
||||
} from "~/lib/pdf-fonts";
|
||||
|
||||
// Fallback download function for better browser compatibility
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
@@ -101,6 +111,8 @@ export interface InvoiceData {
|
||||
export interface PDFGenerationSettings {
|
||||
pdfTemplate?: "classic" | "minimal";
|
||||
pdfAccentColor?: string;
|
||||
pdfFontFamily?: PdfFontFamily;
|
||||
pdfNumericFontFamily?: PdfFontFamily;
|
||||
pdfFooterText?: string;
|
||||
pdfShowLogo?: boolean;
|
||||
pdfShowPageNumbers?: boolean;
|
||||
@@ -109,6 +121,8 @@ export interface PDFGenerationSettings {
|
||||
const defaultPDFSettings: Required<PDFGenerationSettings> = {
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFontFamily: "sans",
|
||||
pdfNumericFontFamily: "mono",
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: true,
|
||||
pdfShowPageNumbers: true,
|
||||
@@ -118,7 +132,95 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
||||
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: {
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#ffffff",
|
||||
@@ -537,7 +639,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
const minimalStyles = StyleSheet.create({
|
||||
const baseMinimalStyles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 9,
|
||||
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) {
|
||||
return showRate
|
||||
? {
|
||||
@@ -766,7 +847,9 @@ function getColumnWidths(showRate: boolean) {
|
||||
const DenseHeader: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, settings }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
|
||||
return (
|
||||
@@ -1029,7 +1112,9 @@ const DenseHeader: React.FC<{
|
||||
const TableHeader: React.FC<{
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
showRate: boolean;
|
||||
}> = ({ settings, showRate }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ settings, showRate, pdfStyles }) => {
|
||||
const { styles, minimalStyles } = pdfStyles;
|
||||
const cols = getColumnWidths(showRate);
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
return (
|
||||
@@ -1094,7 +1179,9 @@ const TableHeader: React.FC<{
|
||||
const NotesSection: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, settings }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles } = pdfStyles;
|
||||
if (!invoice.notes) return null;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
|
||||
@@ -1129,9 +1216,11 @@ const NotesSection: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||
settings,
|
||||
}) => {
|
||||
const Footer: React.FC<{
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles, fonts } = pdfStyles;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
|
||||
return (
|
||||
@@ -1151,7 +1240,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||
<Text
|
||||
style={{
|
||||
fontSize: isMinimal ? 8 : 9,
|
||||
fontFamily: "Helvetica",
|
||||
fontFamily: fonts.regular,
|
||||
color: "#6b7280",
|
||||
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
||||
}}
|
||||
@@ -1176,7 +1265,9 @@ const TotalsSection: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, items, settings }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, items, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles, fonts } = pdfStyles;
|
||||
const currency = invoice.currency ?? "USD";
|
||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
@@ -1206,7 +1297,7 @@ const TotalsSection: React.FC<{
|
||||
<Text
|
||||
style={{
|
||||
fontSize: isMinimal ? 8 : 11,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontFamily: fonts.bold,
|
||||
color: "#0f0f0f",
|
||||
textAlign: isMinimal ? "left" : "center",
|
||||
marginBottom: isMinimal ? 5 : 8,
|
||||
@@ -1301,6 +1392,26 @@ export const InvoicePDF: React.FC<{
|
||||
settings?: PDFGenerationSettings;
|
||||
}> = ({ invoice, settings: 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 currency = invoice.currency ?? "USD";
|
||||
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||
@@ -1313,7 +1424,11 @@ export const InvoicePDF: React.FC<{
|
||||
size="LETTER"
|
||||
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
|
||||
>
|
||||
<DenseHeader invoice={invoice} settings={settings} />
|
||||
<DenseHeader
|
||||
invoice={invoice}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
|
||||
{items.length > 0 && (
|
||||
<View
|
||||
@@ -1322,7 +1437,11 @@ export const InvoicePDF: React.FC<{
|
||||
isMinimal ? minimalStyles.tableContainer : {},
|
||||
]}
|
||||
>
|
||||
<TableHeader settings={settings} showRate={showRate} />
|
||||
<TableHeader
|
||||
settings={settings}
|
||||
showRate={showRate}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
{items.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
@@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{
|
||||
{ width: cols.hours },
|
||||
]}
|
||||
>
|
||||
{item.hours}
|
||||
{isFixedLineItem(item.hours) ? "—" : item.hours}
|
||||
</Text>
|
||||
{showRate && (
|
||||
<Text
|
||||
@@ -1404,12 +1523,21 @@ export const InvoicePDF: React.FC<{
|
||||
wrap={false}
|
||||
>
|
||||
{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>
|
||||
|
||||
<Footer settings={settings} />
|
||||
<Footer settings={settings} pdfStyles={pdfStyles} />
|
||||
</Page>
|
||||
</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 { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
||||
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
||||
import { adminRouter } from "~/server/api/routers/admin";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
||||
recurringInvoices: recurringInvoicesRouter,
|
||||
apiKeys: apiKeysRouter,
|
||||
timeEntries: timeEntriesRouter,
|
||||
admin: adminRouter,
|
||||
});
|
||||
|
||||
// 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"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
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 { 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 };
|
||||
|
||||
@@ -26,11 +41,105 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
|
||||
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({
|
||||
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({
|
||||
where: eq(expenses.createdById, ctx.session.user.id),
|
||||
with: { client: true, business: true, invoice: true },
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
invoice: true,
|
||||
receipts: true,
|
||||
},
|
||||
orderBy: [desc(expenses.date)],
|
||||
});
|
||||
}),
|
||||
@@ -43,7 +152,12 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(expenses.id, input.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) {
|
||||
@@ -69,50 +183,26 @@ export const expensesRouter = createTRPCRouter({
|
||||
};
|
||||
|
||||
if (clean.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
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",
|
||||
});
|
||||
await verifyClientAccess(ctx, clean.clientId);
|
||||
}
|
||||
|
||||
if (clean.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: and(
|
||||
eq(businesses.id, clean.businessId),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!business)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
const invoice = clean.invoiceId
|
||||
? await verifyInvoiceAccess(ctx, clean.invoiceId)
|
||||
: null;
|
||||
|
||||
if (clean.invoiceId) {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, clean.invoiceId),
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!invoice)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
const businessId = await resolveExpenseBusinessId(
|
||||
ctx,
|
||||
clean.businessId,
|
||||
invoice,
|
||||
);
|
||||
|
||||
const [expense] = await ctx.db
|
||||
.insert(expenses)
|
||||
.values({ ...clean, createdById: ctx.session.user.id })
|
||||
.values({
|
||||
...clean,
|
||||
businessId,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return expense;
|
||||
@@ -137,17 +227,56 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const clean = {
|
||||
...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(),
|
||||
};
|
||||
const updates: Record<string, unknown> = { 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 };
|
||||
}),
|
||||
@@ -160,6 +289,7 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(expenses.id, input.id),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
with: { receipts: true },
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
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,
|
||||
} from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import { Resend } from "resend";
|
||||
@@ -22,12 +23,29 @@ type InvoiceRouterContext = {
|
||||
session: { user: { id: string } };
|
||||
};
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
const invoiceItemSchema = z
|
||||
.object({
|
||||
date: z.date(),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
hours: z.number().min(0, "Hours 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({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
@@ -162,7 +180,10 @@ const calculateInvoiceTotal = (
|
||||
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||
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;
|
||||
return subtotal + taxAmount;
|
||||
};
|
||||
@@ -445,7 +466,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
@@ -563,7 +584,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
@@ -863,7 +884,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
dbItems.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
@@ -935,7 +956,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
},
|
||||
{
|
||||
@@ -944,6 +965,16 @@ export const invoicesRouter = createTRPCRouter({
|
||||
| "minimal"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 bcrypt from "bcryptjs";
|
||||
import { logAuditEvent } from "~/lib/audit-log";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { requireAdmin } from "~/server/api/require-admin";
|
||||
import {
|
||||
accounts,
|
||||
users,
|
||||
@@ -26,24 +28,10 @@ import {
|
||||
colorModeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfFontFamilySchema,
|
||||
pdfTemplateSchema,
|
||||
type ColorMode,
|
||||
} 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(
|
||||
refs: { businessName?: string; businessNickname?: string },
|
||||
@@ -237,10 +225,50 @@ export const settingsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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
|
||||
.update(users)
|
||||
.set({ role: input.role })
|
||||
.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 };
|
||||
}),
|
||||
|
||||
@@ -382,6 +410,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
defaultPdfSettings.pdfTemplate,
|
||||
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:
|
||||
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||
@@ -395,6 +429,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
z.object({
|
||||
pdfTemplate: pdfTemplateSchema.optional(),
|
||||
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||
pdfFontFamily: pdfFontFamilySchema.optional(),
|
||||
pdfNumericFontFamily: pdfFontFamilySchema.optional(),
|
||||
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||
pdfShowLogo: z.boolean().optional(),
|
||||
pdfShowPageNumbers: z.boolean().optional(),
|
||||
@@ -409,6 +445,11 @@ export const settingsRouter = createTRPCRouter({
|
||||
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||
pdfFontFamily:
|
||||
input.pdfFontFamily ?? defaultPdfSettings.pdfFontFamily,
|
||||
pdfNumericFontFamily:
|
||||
input.pdfNumericFontFamily ??
|
||||
defaultPdfSettings.pdfNumericFontFamily,
|
||||
pdfFooterText:
|
||||
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||
@@ -422,6 +463,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
...(input.pdfAccentColor && {
|
||||
pdfAccentColor: input.pdfAccentColor,
|
||||
}),
|
||||
...(input.pdfFontFamily && {
|
||||
pdfFontFamily: input.pdfFontFamily,
|
||||
}),
|
||||
...(input.pdfNumericFontFamily && {
|
||||
pdfNumericFontFamily: input.pdfNumericFontFamily,
|
||||
}),
|
||||
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
|
||||
...(input.pdfShowLogo !== undefined && {
|
||||
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 };
|
||||
}),
|
||||
|
||||
// Update user profile
|
||||
updateProfile: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
+72
-1
@@ -41,6 +41,8 @@ export const platformSettings = createTable("platform_setting", (d) => ({
|
||||
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
||||
pdfTemplate: d.varchar({ length: 20 }).default("classic").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
|
||||
.varchar({ length: 120 })
|
||||
.default("Professional Invoicing")
|
||||
@@ -66,6 +68,39 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
invoiceTemplates: many(invoiceTemplates),
|
||||
recurringInvoices: many(recurringInvoices),
|
||||
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(
|
||||
@@ -452,12 +487,37 @@ export const expenses = createTable(
|
||||
index("expense_created_by_idx").on(t.createdById),
|
||||
index("expense_client_id_idx").on(t.clientId),
|
||||
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_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, {
|
||||
fields: [expenses.businessId],
|
||||
references: [businesses.id],
|
||||
@@ -474,8 +534,19 @@ export const expensesRelations = relations(expenses, ({ one }) => ({
|
||||
fields: [expenses.createdById],
|
||||
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(
|
||||
"invoice_template",
|
||||
(d) => ({
|
||||
|
||||
Reference in New Issue
Block a user