diff --git a/.env.example b/.env.example index 6aa463d..36d3873 100644 --- a/.env.example +++ b/.env.example @@ -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) # ============================================================================= diff --git a/.gitignore b/.gitignore index c9876f0..c8b57e7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ next-env.d.ts # misc .DS_Store *.pem +.data/ # debug npm-debug.log* diff --git a/bun.lock b/bun.lock index db1c0b1..b7bc34f 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6d5c0dd..2f8a6cf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 956b3be..86c6b24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/drizzle/0019_pdf_font_family.sql b/drizzle/0019_pdf_font_family.sql new file mode 100644 index 0000000..f004473 --- /dev/null +++ b/drizzle/0019_pdf_font_family.sql @@ -0,0 +1,2 @@ +ALTER TABLE "beenvoice_platform_setting" +ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL; diff --git a/drizzle/0020_pdf_numeric_font_family.sql b/drizzle/0020_pdf_numeric_font_family.sql new file mode 100644 index 0000000..dd251c7 --- /dev/null +++ b/drizzle/0020_pdf_numeric_font_family.sql @@ -0,0 +1,2 @@ +ALTER TABLE "beenvoice_platform_setting" +ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL; diff --git a/drizzle/0021_audit_log.sql b/drizzle/0021_audit_log.sql new file mode 100644 index 0000000..bae1a9c --- /dev/null +++ b/drizzle/0021_audit_log.sql @@ -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"); diff --git a/drizzle/0022_expense_business_receipts.sql b/drizzle/0022_expense_business_receipts.sql new file mode 100644 index 0000000..af1bcd2 --- /dev/null +++ b/drizzle/0022_expense_business_receipts.sql @@ -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; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 32fe057..243fba6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/package.json b/package.json index 987005e..f0bdec3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/docker-deploy.sh b/scripts/docker-deploy.sh index ba8c035..57e9fa3 100755 --- a/scripts/docker-deploy.sh +++ b/scripts/docker-deploy.sh @@ -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. diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 6ced93e..99c95ac 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -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 `, - 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( { diff --git a/src/app/api/i/[token]/pdf/route.ts b/src/app/api/i/[token]/pdf/route.ts index 362e7d6..074f60f 100644 --- a/src/app/api/i/[token]/pdf/route.ts +++ b/src/app/api/i/[token]/pdf/route.ts @@ -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, diff --git a/src/app/api/receipts/[id]/route.ts b/src/app/api/receipts/[id]/route.ts new file mode 100644 index 0000000..70e137c --- /dev/null +++ b/src/app/api/receipts/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/dashboard/administration/_components/administration-content.tsx b/src/app/dashboard/administration/_components/administration-content.tsx index d630c7c..ad24645 100644 --- a/src/app/dashboard/administration/_components/administration-content.tsx +++ b/src/app/dashboard/administration/_components/administration-content.tsx @@ -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,84 +59,563 @@ 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 = { + "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 ( - + - + Platform overview + Unable to load statistics. + + + ); + } + + 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 ( +
+ + + - Administration + Platform overview - Administrative access is required for this page. + Aggregate counts only — no customer data, credentials, or bulk PII. + + {isLoading ? ( +

Loading statistics…

+ ) : ( +
+ {statCards.map((stat) => ( + + +
+ + {stat.label} +
+

{stat.value}

+
+
+ ))} +
+ )} +
+
+
+ ); +} + +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(null); + const [resetUserId, setResetUserId] = useState(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 ( + + + Users + Administrative access is required. + ); } return ( - + <> + + + + + Users + + + Search accounts, edit profiles, and manage access. + + + +
+ + { + setSearch(event.target.value); + setOffset(0); + }} + placeholder="Search by name or email…" + className="pl-9" + /> +
+ + {isLoading ? ( +

Loading users…

+ ) : users.length === 0 ? ( + } + title="No users found" + description={ + deferredSearch + ? "Try a different search term." + : "No accounts have been created yet." + } + /> + ) : ( +
+ {users.map((user) => ( +
+
+
+

{user.name}

+ + {user.role} + + {user.emailVerified ? ( + + Verified + + ) : null} +
+

+ {user.email} +

+

+ Joined{" "} + {new Date(user.createdAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+
+ + +
+
+ ))} +
+ )} + + {total > PAGE_SIZE ? ( +
+

+ Page {currentPage} of {totalPages} · {total} users + {isFetching ? " · Updating…" : ""} +

+
+ + +
+
+ ) : null} +
+
+ + { + if (!open) setEditUser(null); + }} + > + + + Edit user + + {editUser ? ( +
{ + event.preventDefault(); + updateUserMutation.mutate({ + userId: editUser.id, + name: editUser.name, + email: editUser.email, + role: editUser.role, + }); + }} + > +
+ + + setEditUser((current) => + current + ? { ...current, name: event.target.value } + : current, + ) + } + required + /> +
+
+ + + setEditUser((current) => + current + ? { ...current, email: event.target.value } + : current, + ) + } + required + /> +
+
+ + +
+ + + + +
+ ) : null} +
+
+ + { + if (!open) setResetUserId(null); + }} + > + + + Send password reset? + + A password reset email will be sent to{" "} + {resetUserName}. The link + expires in 24 hours. + + + + Cancel + { + if (resetUserId) { + sendResetMutation.mutate({ userId: resetUserId }); + } + }} + > + {sendResetMutation.isPending ? "Sending…" : "Send reset email"} + + + + + + ); +} + +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 ( + + + Audit log + Administrative access is required. + + + ); + } + + return ( + - - - Accounts + + + Audit log - Manage account access and roles without opening customer data. + Recent administrative actions across the platform. - - {accounts.map((account) => ( -
-
-

{account.name}

-

- {account.email} -

-

- Created {new Date(account.createdAt).toLocaleDateString()} -

-
- + + {isLoading ? ( +

Loading audit log…

+ ) : entries.length === 0 ? ( + } + title="No audit events yet" + description="Administrative actions will appear here." + /> + ) : ( +
+ {entries.map((entry) => ( +
+
+

+ {formatAction(entry.action)} +

+ + {entry.targetType} + +
+

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

+ {entry.metadata && + Object.keys(entry.metadata).length > 0 ? ( +

+ {JSON.stringify(entry.metadata)} +

+ ) : null} +
+ ))}
- ))} + )} + + {total > PAGE_SIZE ? ( +
+

+ Page {currentPage} of {totalPages} · {total} events + {isFetching ? " · Updating…" : ""} +

+
+ + +
+
+ ) : null}
); } + +export function AdministrationContent() { + return ( + + + Overview + Users + Audit log + + + + + + + + + + + + + + + ); +} diff --git a/src/app/dashboard/administration/page.tsx b/src/app/dashboard/administration/page.tsx index e8a96ad..a09136b 100644 --- a/src/app/dashboard/administration/page.tsx +++ b/src/app/dashboard/administration/page.tsx @@ -28,7 +28,7 @@ export default async function AdministrationPage() { diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index 6fc7085..90b7e64 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -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(null); const [form, setForm] = useState(defaultForm); const [deleteId, setDeleteId] = useState(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((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() { +
+ + +
+
@@ -202,7 +306,6 @@ export default function ExpensesPage() {
- {/* Expenses list */} @@ -259,6 +362,12 @@ export default function ExpensesPage() { {expense.category} )} + {(expense.receipts?.length ?? 0) > 0 && ( + + + {expense.receipts?.length} + + )}

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

{expense.notes && ( @@ -302,8 +412,16 @@ export default function ExpensesPage() {
- {/* Add/Edit dialog */} - + { + setOpen(next); + if (!next) { + setEditId(null); + setForm(defaultForm); + } + }} + > {editId ? "Edit Expense" : "Add Expense"} @@ -381,6 +499,31 @@ export default function ExpensesPage() { +
+ + +
+ + + + + All businesses + {businesses.map((b) => ( + + {b.name} + + ))} + + +
+ @@ -573,7 +599,7 @@ export default function ReportsPage() {
@@ -584,7 +610,7 @@ export default function ReportsPage() { ), )} - {invoices.length === 0 && ( + {filteredInvoices.length === 0 && (

No invoices yet.

diff --git a/src/app/dashboard/settings/_components/settings-content.tsx b/src/app/dashboard/settings/_components/settings-content.tsx index cfaaa75..0fc5f08 100644 --- a/src/app/dashboard/settings/_components/settings-content.tsx +++ b/src/app/dashboard/settings/_components/settings-content.tsx @@ -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) { - // 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]); + 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 ?? 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" /> + +
+ + +

+ { + pdfFontFamilyOptions.find( + (option) => + option.value === + (pdfSettings?.pdfFontFamily ?? "sans"), + )?.description + } +

+
+ +
+ + +

+ Used for dates, hours, rates, and totals.{" "} + { + pdfFontFamilyOptions.find( + (option) => + option.value === + (pdfSettings?.pdfNumericFontFamily ?? "mono"), + )?.description + } +

+
@@ -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, diff --git a/src/app/dashboard/time-clock/entries/page.tsx b/src/app/dashboard/time-clock/entries/page.tsx new file mode 100644 index 0000000..60d690b --- /dev/null +++ b/src/app/dashboard/time-clock/entries/page.tsx @@ -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 ( + + + + + + + + + ); +} diff --git a/src/app/i/[token]/page.tsx b/src/app/i/[token]/page.tsx index 4901c2c..56a5ec9 100644 --- a/src/app/i/[token]/page.tsx +++ b/src/app/i/[token]/page.tsx @@ -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 }) {

{item.description}

- {item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr + {formatLineItemDetail( + item.hours, + item.rate, + (amount) => formatCurrency(amount, invoice.currency ?? "USD"), + )}

diff --git a/src/components/forms/business-form.tsx b/src/components/forms/business-form.tsx index 90769e6..29bd73a 100644 --- a/src/components/forms/business-form.tsx +++ b/src/components/forms/business-form.tsx @@ -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 })); diff --git a/src/components/forms/client-form.tsx b/src/components/forms/client-form.tsx index 3bcf1ac..234a340 100644 --- a/src/components/forms/client-form.tsx +++ b/src/components/forms/client-form.tsx @@ -90,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { const [errors, setErrors] = useState({}); 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 })); diff --git a/src/components/forms/email-preview.tsx b/src/components/forms/email-preview.tsx index e3967bb..b04aad9 100644 --- a/src/components/forms/email-preview.tsx +++ b/src/components/forms/email-preview.tsx @@ -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, diff --git a/src/components/forms/invoice-calendar-view.tsx b/src/components/forms/invoice-calendar-view.tsx index e9d9dac..d99ee3e 100644 --- a/src/components/forms/invoice-calendar-view.tsx +++ b/src/components/forms/invoice-calendar-view.tsx @@ -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 - ${(item.hours * item.rate).toFixed(2)} + ${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}

diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index 00c74c9..babfce2 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -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; @@ -266,9 +274,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { items: formData.items.map((item) => ({ date: item.date, description: item.description || "Service", - hours: item.hours, - rate: item.rate, - })), + 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) { - const updated = { ...item, [field]: value }; - if (field === "hours" || field === "rate") { - updated.amount = updated.hours * updated.rate; - } - return updated; + if (i !== idx) return item; + + if (field === "billingType" && (value === "hourly" || value === "fixed")) { + const next = applyBillingTypeChange(value, item); + return { + ...item, + ...next, + billingType: value, + }; } - return item; + + const updated = { ...item, [field]: value }; + if (field === "hours" || field === "rate") { + updated.amount = calculateLineItemAmount(updated.hours, updated.rate); + updated.billingType = getLineItemBillingType(updated.hours); + } + return updated; }), })); }; @@ -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) { Hours s + i.hours, 0)} + value={formData.items.reduce( + (s, i) => s + (i.hours > 0 ? i.hours : 0), + 0, + )} suffix="h" /> @@ -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), })), }} /> diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx index b1b9768..51e836b 100644 --- a/src/components/forms/invoice-line-items.tsx +++ b/src/components/forms/invoice-line-items.tsx @@ -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( ({ 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 ( + +
+ + +
+ +
+ + setStopNote(e.target.value)} + placeholder={ + running?.description?.trim() + ? running.description + : "Update description when you stop" + } + /> +
+ )} {running ? ( @@ -529,66 +611,28 @@ export function TimeClockPanel({
- {!compact && todayEntries && todayEntries.length > 0 ? ( + {!compact ? ( - + Today's entries + - {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 = ( - <> -
-

- {formatRunningTimerLabel(entry.description)} -

-

- {entry.client?.name ?? "No client"} - {entry.invoice - ? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` - : entry.hours - ? " · not on invoice" - : ""} -

-
-
-

{entry.hours ?? "—"}h

- {entry.rate ? ( -

${entry.rate}/hr

- ) : null} -
- - ); - - if (href) { - return ( - - {content} - - ); - } - - return ( -
- {content} -
- ); - })} + {todayEntries?.some((e) => e.endedAt) ? ( + + ) : ( +

+ No entries today.{" "} + + View history + +

+ )}
) : null} diff --git a/src/components/time-clock/time-entries-history.tsx b/src/components/time-clock/time-entries-history.tsx new file mode 100644 index 0000000..cd75289 --- /dev/null +++ b/src/components/time-clock/time-entries-history.tsx @@ -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 ( + + + Loading entries… + + + ); + } + + if (completedEntries.length === 0) { + return ( + + + } + title="No time entries yet" + description="Start the timer to track billable hours. Completed entries will show up here." + action={ + + } + className="py-16" + /> + + + ); + } + + return ( +
+ {grouped.map((group) => ( + + + + {group.label} + + + + {group.entries.map((entry, index) => ( + + ))} + + + ))} +
+ ); +} diff --git a/src/components/time-clock/time-entry-list.tsx b/src/components/time-clock/time-entry-list.tsx new file mode 100644 index 0000000..c69ca24 --- /dev/null +++ b/src/components/time-clock/time-entry-list.tsx @@ -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 = ( + <> +
+

{formatRunningTimerLabel(entry.description)}

+

+ {entry.client?.name ?? "No client"} + {entry.invoice + ? ` · ${invoiceLabel(entry.invoice)}` + : entry.hours + ? " · not on invoice" + : ""} +

+
+
+

{entry.hours ?? "—"}h

+ {entry.rate ?

${entry.rate}/hr

: null} +
+ + ); + + if (href) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} + +export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) { + const completed = entries.filter((e) => e.endedAt); + + if (completed.length === 0) return null; + + return ( + <> + {completed.map((entry, index) => ( + + ))} + + ); +} diff --git a/src/env.js b/src/env.js index ebaa9e8..64f1e85 100644 --- a/src/env.js +++ b/src/env.js @@ -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, diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index f56f8db..7dedfd0 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -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; export type PdfTemplate = z.infer; @@ -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, diff --git a/src/lib/audit-log.ts b/src/lib/audit-log.ts new file mode 100644 index 0000000..997852b --- /dev/null +++ b/src/lib/audit-log.ts @@ -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; +}; + +export async function logAuditEvent(input: LogAuditEventInput): Promise { + await db.insert(auditLog).values({ + actorUserId: input.actorUserId, + action: input.action, + targetType: input.targetType, + targetId: input.targetId, + metadata: input.metadata, + }); +} diff --git a/src/lib/branding.ts b/src/lib/branding.ts index 290291d..b0ee3ac 100644 --- a/src/lib/branding.ts +++ b/src/lib/branding.ts @@ -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"; diff --git a/src/lib/invoice-line-item.ts b/src/lib/invoice-line-item.ts new file mode 100644 index 0000000..49601a0 --- /dev/null +++ b/src/lib/invoice-line-item.ts @@ -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 }; +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index ce2dad8..5cc2d0c 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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; } diff --git a/src/lib/object-storage.ts b/src/lib/object-storage.ts new file mode 100644 index 0000000..ed62744 --- /dev/null +++ b/src/lib/object-storage.ts @@ -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 | null = null; +let s3Client: InstanceType | 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 { + 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 { + 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 { + 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/"); +} diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts new file mode 100644 index 0000000..aaac19d --- /dev/null +++ b/src/lib/password-reset.ts @@ -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 { + 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 `, + 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 }; + } +} diff --git a/src/lib/pdf-export.tsx b/src/lib/pdf-export.tsx index aeacb8b..ec56f1e 100644 --- a/src/lib/pdf-export.tsx +++ b/src/lib/pdf-export.tsx @@ -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 = { 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( + 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>; +}; + +const pdfStyleCache = new Map(); + +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; -}> = ({ 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; 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; -}> = ({ 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 }> = ({ - settings, -}) => { +const Footer: React.FC<{ + settings: Required; + 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 }> = ({ [0]>; settings: Required; -}> = ({ 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<{ = ({ invoice, settings: inputSettings }) => { const settings = resolvePDFSettings(inputSettings); + const pdfStyles = getPdfStyleBundle( + settings.pdfFontFamily, + settings.pdfNumericFontFamily, + ); + + return ( + + ); +}; + +const InvoicePDFDocument: React.FC<{ + invoice: InvoiceData; + settings: Required; + 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 : {}]} > - + {items.length > 0 && ( - + {items.map( (item, index) => item && ( @@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{ { width: cols.hours }, ]} > - {item.hours} + {isFixedLineItem(item.hours) ? "—" : item.hours} {showRate && ( {invoice.notes && ( - + )} - + -