diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 4c8bf632..a483b29b 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -2,16 +2,16 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] env: CARGO_TERM_COLOR: always defaults: run: - working-directory: contracts # 👈 all cargo commands run in contracts/ + working-directory: contracts # 👈 all cargo commands run in contracts/ jobs: format: @@ -94,4 +94,74 @@ jobs: ${{ runner.os }}-cargo-build- ${{ runner.os }}-cargo- - name: Build all crates - run: cargo build --all --verbose \ No newline at end of file + run: cargo build --all --verbose + + # ───────────────────────────────────────────── + # BACKEND — NestJS + # ───────────────────────────────────────────── + backend: + name: Backend (NestJS) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build (TypeScript check) + run: npm run build + + - name: Unit tests + run: npm run test -- --passWithNoTests + + # ───────────────────────────────────────────── + # FRONTEND — Next.js + # ───────────────────────────────────────────── + frontend: + name: Frontend (Next.js) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 + + - name: Unit tests + run: npm run test -- --passWithNoTests diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c2658d7d..00000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/backend/package-lock.json b/backend/package-lock.json index 0dbde9c0..01d40fe5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.15", + "@stellar/stellar-sdk": "^14.5.0", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", @@ -1275,6 +1276,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2847,6 +2849,7 @@ "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", + "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2892,6 +2895,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz", "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", "hasInstallScript": true, + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2985,6 +2989,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", @@ -3166,6 +3171,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.15.tgz", "integrity": "sha512-OmCUJwvtagzXfMVko595O98UI3M9zg+URL+/HV7vd3QPMCZ3uGCKSq15YYJ99LHJn9NyK4e4Szm2KnHtUg2QzA==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -3184,11 +3190,25 @@ } } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3363,6 +3383,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4183,6 +4204,94 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-base/node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@stellar/stellar-base/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.5.0.tgz", + "integrity": "sha512-Uzjq+An/hUA+Q5ERAYPtT0+MMiwWnYYWMwozmZMjxjdL2MmSjucBDF8Q04db6K/ekU4B5cHuOfsdlrfaxQYblw==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.0.4", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@streamparser/json": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", @@ -4361,6 +4470,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4524,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4763,6 +4874,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -5140,6 +5252,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5152,7 +5265,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -5186,6 +5298,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5503,13 +5616,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -5712,6 +5825,15 @@ "node": ">=0.6" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", @@ -5841,6 +5963,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5941,6 +6064,7 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -5987,6 +6111,7 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", + "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -6018,6 +6143,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -6266,13 +6392,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7157,6 +7285,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7212,6 +7341,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7437,6 +7567,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -7524,6 +7663,7 @@ "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7690,6 +7830,15 @@ "bser": "2.1.1" } }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7962,9 +8111,10 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8710,6 +8860,18 @@ "node": ">=8" } }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -8862,6 +9024,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9748,6 +9911,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10718,6 +10882,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10847,6 +11012,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -11107,6 +11273,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11376,7 +11543,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -11470,6 +11636,7 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", "license": "MIT", + "peer": true, "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", @@ -11791,6 +11958,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12758,6 +12926,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -12882,6 +13056,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13032,6 +13207,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^3.17.0", @@ -13183,6 +13359,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13361,6 +13538,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13528,7 +13711,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13542,7 +13724,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -13552,7 +13733,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/package.json b/backend/package.json index 3e47de34..cc9473e2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,6 +36,7 @@ "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.15", + "@stellar/stellar-sdk": "^14.5.0", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts index 7864ede4..927d7cca 100644 --- a/backend/src/app.service.ts +++ b/backend/src/app.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { diff --git a/backend/src/assets/asset-note.entity.ts b/backend/src/assets/asset-note.entity.ts index 451429dd..3f01b07c 100644 --- a/backend/src/assets/asset-note.entity.ts +++ b/backend/src/assets/asset-note.entity.ts @@ -3,7 +3,6 @@ import { PrimaryGeneratedColumn, Column, ManyToOne, - JoinColumn, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; diff --git a/backend/src/categories/categories.controller.spec.ts b/backend/src/categories/categories.controller.spec.ts deleted file mode 100644 index c59972a3..00000000 --- a/backend/src/categories/categories.controller.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CategoriesController } from './categories.controller'; -import { CategoriesService } from './categories.service'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -const mockCategory = { id: 'uuid-1', name: 'Electronics' }; -const mockCategoryWithCount = { ...mockCategory, assetCount: 5 }; - -const mockService = { - findAll: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - remove: jest.fn(), -}; - -describe('CategoriesController', () => { - let controller: CategoriesController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CategoriesController], - providers: [{ provide: CategoriesService, useValue: mockService }], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: () => true }) - .compile(); - - controller = module.get(CategoriesController); - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return all categories with asset counts', async () => { - mockService.findAll.mockResolvedValue([mockCategoryWithCount]); - - const result = await controller.findAll(); - - expect(mockService.findAll).toHaveBeenCalledTimes(1); - expect(result).toEqual([mockCategoryWithCount]); - }); - }); - - describe('findOne', () => { - it('should return a single category by id', async () => { - mockService.findOne.mockResolvedValue(mockCategory); - - const result = await controller.findOne('uuid-1'); - - expect(mockService.findOne).toHaveBeenCalledWith('uuid-1'); - expect(result).toEqual(mockCategory); - }); - - it('should propagate exceptions from the service', async () => { - mockService.findOne.mockRejectedValue(new Error('Category not found')); - - await expect(controller.findOne('missing-id')).rejects.toThrow('Category not found'); - }); - }); - - describe('create', () => { - const dto = { name: 'Electronics' }; - - it('should create and return a new category', async () => { - mockService.create.mockResolvedValue(mockCategory); - - const result = await controller.create(dto); - - expect(mockService.create).toHaveBeenCalledWith(dto); - expect(result).toEqual(mockCategory); - }); - - it('should propagate conflict exceptions from the service', async () => { - mockService.create.mockRejectedValue(new Error('A category with this name already exists')); - - await expect(controller.create(dto)).rejects.toThrow( - 'A category with this name already exists', - ); - }); - }); - - describe('remove', () => { - it('should call service.remove with the correct id', async () => { - mockService.remove.mockResolvedValue(undefined); - - const result = await controller.remove('uuid-1'); - - expect(mockService.remove).toHaveBeenCalledWith('uuid-1'); - expect(result).toBeUndefined(); - }); - - it('should propagate exceptions from the service', async () => { - mockService.remove.mockRejectedValue(new Error('Category not found')); - - await expect(controller.remove('missing-id')).rejects.toThrow('Category not found'); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/categories/categories.service.spec.ts b/backend/src/categories/categories.service.spec.ts deleted file mode 100644 index fe50a500..00000000 --- a/backend/src/categories/categories.service.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { NotFoundException, ConflictException } from '@nestjs/common'; -import { CategoriesService } from './categories.service'; -import { Category } from './category.entity'; - -const mockCategory: Category = { - id: 'uuid-1', - name: 'Electronics', -} as Category; - -const mockRepo = { - query: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - remove: jest.fn(), -}; - -describe('CategoriesService', () => { - let service: CategoriesService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CategoriesService, - { provide: getRepositoryToken(Category), useValue: mockRepo }, - ], - }).compile(); - - service = module.get(CategoriesService); - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return categories with numeric assetCount', async () => { - const rawRows = [ - { ...mockCategory, assetCount: '3' }, - { id: 'uuid-2', name: 'Furniture', assetCount: '0' }, - ]; - mockRepo.query.mockResolvedValue(rawRows); - - const result = await service.findAll(); - - expect(mockRepo.query).toHaveBeenCalledTimes(1); - expect(result).toEqual([ - { ...mockCategory, assetCount: 3 }, - { id: 'uuid-2', name: 'Furniture', assetCount: 0 }, - ]); - expect(typeof result[0].assetCount).toBe('number'); - }); - - it('should return an empty array when no categories exist', async () => { - mockRepo.query.mockResolvedValue([]); - const result = await service.findAll(); - expect(result).toEqual([]); - }); - }); - - describe('findOne', () => { - it('should return a category when found', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - - const result = await service.findOne('uuid-1'); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); - expect(result).toEqual(mockCategory); - }); - - it('should throw NotFoundException when category does not exist', async () => { - mockRepo.findOne.mockResolvedValue(null); - - await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); - await expect(service.findOne('missing-id')).rejects.toThrow('Category not found'); - }); - }); - - describe('create', () => { - const dto = { name: 'Electronics' }; - - it('should create and return a new category', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.create.mockReturnValue(mockCategory); - mockRepo.save.mockResolvedValue(mockCategory); - - const result = await service.create(dto); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { name: dto.name } }); - expect(mockRepo.create).toHaveBeenCalledWith(dto); - expect(mockRepo.save).toHaveBeenCalledWith(mockCategory); - expect(result).toEqual(mockCategory); - }); - - it('should throw ConflictException when a category with the same name exists', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - - await expect(service.create(dto)).rejects.toThrow(ConflictException); - await expect(service.create(dto)).rejects.toThrow( - 'A category with this name already exists', - ); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - }); - - describe('remove', () => { - it('should remove the category successfully', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - mockRepo.remove.mockResolvedValue(undefined); - - await service.remove('uuid-1'); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); - expect(mockRepo.remove).toHaveBeenCalledWith(mockCategory); - }); - - it('should throw NotFoundException if category does not exist', async () => { - mockRepo.findOne.mockResolvedValue(null); - - await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); - expect(mockRepo.remove).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/categories/dto/create-category.dto.ts b/backend/src/categories/dto/create-category.dto.ts index 9501e61c..a1692d84 100644 --- a/backend/src/categories/dto/create-category.dto.ts +++ b/backend/src/categories/dto/create-category.dto.ts @@ -3,14 +3,12 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; export class CreateCategoryDto { @ApiProperty({ example: 'Laptop' }) - @ApiDescription('The name of the category') @IsString() @IsNotEmpty() @MaxLength(100) name: string; @ApiPropertyOptional({ example: 'Portable computing devices' }) - @ApiDescription('A brief description of the category') @IsString() @IsOptional() @MaxLength(500) diff --git a/backend/src/departments/department.entity.ts b/backend/src/departments/department.entity.ts new file mode 100644 index 00000000..9fc3cbef --- /dev/null +++ b/backend/src/departments/department.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('departments') +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ nullable: true }) + description: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts new file mode 100644 index 00000000..5fb1642a --- /dev/null +++ b/backend/src/departments/departments.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { DepartmentsService } from './departments.service'; +import { CreateDepartmentDto } from './dto/create-department.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('Departments') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('departments') +export class DepartmentsController { + constructor(private readonly service: DepartmentsService) {} + + @Get() + @ApiOperation({ summary: 'List all departments' }) + findAll() { + return this.service.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a department by ID' }) + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Post() + @ApiOperation({ summary: 'Create a new department' }) + create(@Body() dto: CreateDepartmentDto) { + return this.service.create(dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a department' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/backend/src/departments/departments.module.ts b/backend/src/departments/departments.module.ts new file mode 100644 index 00000000..a8cbe033 --- /dev/null +++ b/backend/src/departments/departments.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Department } from './department.entity'; +import { DepartmentsService } from './departments.service'; +import { DepartmentsController } from './departments.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Department])], + controllers: [DepartmentsController], + providers: [DepartmentsService], + exports: [DepartmentsService], +}) +export class DepartmentsModule {} diff --git a/backend/src/departments/departments.service.ts b/backend/src/departments/departments.service.ts new file mode 100644 index 00000000..c28343de --- /dev/null +++ b/backend/src/departments/departments.service.ts @@ -0,0 +1,45 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from './department.entity'; +import { CreateDepartmentDto } from './dto/create-department.dto'; + +export interface DepartmentWithCount extends Department { + assetCount: number; +} + +@Injectable() +export class DepartmentsService { + constructor( + @InjectRepository(Department) + private readonly repo: Repository, + ) {} + + async findAll(): Promise { + const rows: (Department & { assetCount: string })[] = await this.repo.query(` + SELECT d.*, COALESCE(COUNT(a.id), 0)::int AS "assetCount" + FROM departments d + LEFT JOIN assets a ON a."departmentId" = d.id + GROUP BY d.id + ORDER BY d.name ASC + `); + return rows.map((r) => ({ ...r, assetCount: Number(r.assetCount) })); + } + + async findOne(id: string): Promise { + const dept = await this.repo.findOne({ where: { id } }); + if (!dept) throw new NotFoundException('Department not found'); + return dept; + } + + async create(dto: CreateDepartmentDto): Promise { + const existing = await this.repo.findOne({ where: { name: dto.name } }); + if (existing) throw new ConflictException('A department with this name already exists'); + return this.repo.save(this.repo.create(dto)); + } + + async remove(id: string): Promise { + const dept = await this.findOne(id); + await this.repo.remove(dept); + } +} diff --git a/backend/src/departments/dto/create-department.dto.ts b/backend/src/departments/dto/create-department.dto.ts new file mode 100644 index 00000000..d01cf4f5 --- /dev/null +++ b/backend/src/departments/dto/create-department.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateDepartmentDto { + @ApiProperty({ example: 'Engineering' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ example: 'Software engineering department' }) + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; +} diff --git a/contracts/assetsup/src/tests/helpers.rs b/contracts/assetsup/src/tests/helpers.rs index c527d67d..29ad5a60 100644 --- a/contracts/assetsup/src/tests/helpers.rs +++ b/contracts/assetsup/src/tests/helpers.rs @@ -1,5 +1,7 @@ use crate::asset::Asset; -use crate::insurance::{ClaimStatus, ClaimType, InsuranceClaim, InsurancePolicy, PolicyStatus, PolicyType}; +use crate::insurance::{ + ClaimStatus, ClaimType, InsuranceClaim, InsurancePolicy, PolicyStatus, PolicyType, +}; use crate::types::{AssetStatus, AssetType, CustomAttribute, TokenMetadata}; use crate::{AssetUpContract, AssetUpContractClient}; use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Vec}; diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx index b1d459a6..dd073cd4 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -1,24 +1,25 @@ -import { ReactNode } from 'react'; - -export default function AuthLayout({ - children, -}: { - children: ReactNode; -}) { +export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( -
-
- {/* Logo/Wordmark */} -
-
-

AssetsUp

-

Asset Management System

+
+
+ {/* Logo / Brand */} +
+
+ + +
+

AssetsUp

+

Asset & Inventory Management

-
-
-
+ {/* Card */} +
{children}
diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 981be549..631850a1 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,135 +1,107 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useLoginMutation } from "@/lib/query/mutations/auth"; -import { useAuthStore } from "@/store/auth.store"; -import { Button } from "@/components/ui/button"; +import { Suspense } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth.store'; -// Zod schema for login validation -const loginSchema = z.object({ - email: z.string().email("Invalid email address"), - password: z.string().min(1, "Password is required"), +const schema = z.object({ + email: z.string().email('Enter a valid email address'), + password: z.string().min(1, 'Password is required'), }); -type LoginFormData = z.infer; +type FormValues = z.infer; -export default function LoginPage() { +function LoginForm() { const router = useRouter(); - const { setAuth } = useAuthStore(); - const [apiError, setApiError] = useState(""); + const searchParams = useSearchParams(); + const { login, isLoading } = useAuthStore(); const { register, handleSubmit, - formState: { errors }, setError, - } = useForm({ - resolver: zodResolver(loginSchema), - }); - - const loginMutation = useLoginMutation({ - onSuccess: (data) => { - setAuth(data.token, data.user); - router.push("/dashboard"); - }, - onError: (error: any) => { - if (error.statusCode === 401) { - setApiError("Invalid email or password"); - } else if (error.errors) { - // Handle field-specific errors from API - Object.entries(error.errors).forEach(([field, messages]) => { - setError(field as keyof LoginFormData, { - message: Array.isArray(messages) ? messages[0] : messages, - }); - }); - } else { - setApiError(error.message || "Login failed"); - } - }, - }); + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); - const onSubmit = (data: LoginFormData) => { - setApiError(""); - loginMutation.mutate(data); + const onSubmit = async (values: FormValues) => { + try { + await login(values); + const redirect = searchParams.get('redirect') || '/dashboard'; + router.push(redirect); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + 'Something went wrong. Please try again.'; + setError('root', { message }); + } }; return ( -
-
-

Sign in to your account

-

- Or{" "} - - create a new account - -

-
+ <> +

Welcome back

+

Sign in to your account to continue

-
- {/* Email Field */} -
- -
- - {errors.email && ( -

{errors.email.message}

- )} -
-
+ + - {/* Password Field */} -
- -
- - {errors.password && ( -

{errors.password.message}

- )} +
+
+ + + Forgot password? +
+
- {/* API Error */} - {apiError && ( -
-
{apiError}
-
+ {errors.root && ( +

+ {errors.root.message} +

)} - {/* Submit Button */} -
- -
+ -
+ +

+ Don't have an account?{' '} + + Create one + +

+ + ); +} + +export default function LoginPage() { + return ( + + + ); } diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx index 93fe3d5c..2cf6d483 100644 --- a/frontend/app/(auth)/register/page.tsx +++ b/frontend/app/(auth)/register/page.tsx @@ -1,232 +1,110 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useRegisterMutation, useLoginMutation } from "@/lib/query/mutations/auth"; -import { useAuthStore } from "@/store/auth.store"; -import { Button } from "@/components/ui/button"; - -// Zod schema for registration validation -const registerSchema = z - .object({ - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - email: z.string().email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), - confirmPassword: z.string().min(1, "Please confirm your password"), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }); - -type RegisterFormData = z.infer; +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth.store'; + +const schema = z.object({ + firstName: z.string().min(1, 'First name is required').max(50), + lastName: z.string().min(1, 'Last name is required').max(50), + email: z.string().email('Enter a valid email address'), + password: z + .string() + .min(8, 'Password must be at least 8 characters'), +}); + +type FormValues = z.infer; export default function RegisterPage() { const router = useRouter(); - const { setAuth } = useAuthStore(); - const [apiError, setApiError] = useState(""); - - const [password, setPassword] = useState(""); + const { register: registerUser, isLoading } = useAuthStore(); const { register, handleSubmit, - formState: { errors }, setError, - } = useForm({ - resolver: zodResolver(registerSchema), - }); - - const registerMutation = useRegisterMutation({ - onSuccess: (data) => { - // Auto-login after successful registration - loginMutation.mutate({ - email: data.user.email, - password: password, // Use the password from form state - }); - }, - onError: (error: any) => { - if (error.errors) { - // Handle field-specific errors from API - Object.entries(error.errors).forEach(([field, messages]) => { - setError(field as keyof RegisterFormData, { - message: Array.isArray(messages) ? messages[0] : messages, - }); - }); - } else { - setApiError(error.message || "Registration failed"); - } - }, - }); - - const loginMutation = useLoginMutation({ - onSuccess: (data) => { - setAuth(data.token, data.user); - router.push("/dashboard"); - }, - onError: (error: any) => { - setApiError("Registration successful but login failed. Please try logging in manually."); - }, - }); - - const onSubmit = (data: RegisterFormData) => { - setApiError(""); - setPassword(data.password); // Store password for auto-login - - const { confirmPassword, ...registerData } = data; - - registerMutation.mutate({ - ...registerData, - name: `${data.firstName} ${data.lastName}`, - }); + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); + + const onSubmit = async (values: FormValues) => { + try { + await registerUser(values); + router.push('/dashboard'); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + 'Something went wrong. Please try again.'; + setError('root', { message }); + } }; return ( -
-
-

Create your account

-

- Already have an account?{" "} - - Sign in - -

-
- -
- {/* Name Fields */} -
-
- -
- - {errors.firstName && ( -

{errors.firstName.message}

- )} -
-
- -
- -
- - {errors.lastName && ( -

{errors.lastName.message}

- )} -
-
+ <> +

Create your account

+

Start managing your assets today

+ + +
+ +
- {/* Email Field */} -
- -
- - {errors.email && ( -

{errors.email.message}

- )} -
-
- - {/* Password Fields */} -
-
- -
- - {errors.password && ( -

{errors.password.message}

- )} -
-
- -
- -
- - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
-
-
- - {/* API Error */} - {apiError && ( -
-
{apiError}
-
+ + + + + {errors.root && ( +

+ {errors.root.message} +

)} - {/* Submit Button */} -
- -
+ -
+ +

+ Already have an account?{' '} + + Sign in + +

+ ); } diff --git a/frontend/app/(dashboard)/assets/[id]/page.tsx b/frontend/app/(dashboard)/assets/[id]/page.tsx index c53d409d..c033518b 100644 --- a/frontend/app/(dashboard)/assets/[id]/page.tsx +++ b/frontend/app/(dashboard)/assets/[id]/page.tsx @@ -1,3 +1,4 @@ +// frontend/app/(public)/assets/[id]/page.tsx "use client"; import { useState } from "react"; @@ -8,18 +9,13 @@ import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; import { useAsset, useAssetHistory } from "@/lib/query/hooks/useAsset"; -import { useAuthStore } from "@/store/auth.store"; -import { TransferAssetDialog } from "@/components/assets/transfer-dialog"; -import { MoveHorizontal } from "lucide-react"; -type Tab = "overview" | "history" | "documents"; +type Tab = "overview" | "history"; export default function AssetDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); const [tab, setTab] = useState("overview"); - const [isTransferOpen, setIsTransferOpen] = useState(false); - const { user } = useAuthStore(); const { data: asset, isLoading } = useAsset(id); const { data: history = [] } = useAssetHistory(id); @@ -46,7 +42,6 @@ export default function AssetDetailPage() { const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ { key: "overview", label: "Overview", icon: }, { key: "history", label: "History", icon: }, - { key: "documents", label: "Documents", icon: }, ]; return ( @@ -78,16 +73,6 @@ export default function AssetDetailPage() {
- - {(user?.role === 'ADMIN' || user?.role === 'MANAGER') && ( - - )}
@@ -97,10 +82,11 @@ export default function AssetDetailPage() {
)} - - {tab === "documents" && } - - {isTransferOpen && ( - setIsTransferOpen(false)} - /> - )} -
- ); -} - -// Asset Documents Section -import { - useAssetDocuments, - useUploadDocument, - useDeleteDocument, -} from "@/lib/query/hooks/useAsset"; - -function AssetDocumentsSection({ assetId }: { assetId: string }) { - const { data: documents = [], isLoading } = useAssetDocuments(assetId); - const uploadMutation = useUploadDocument(assetId); - const deleteMutation = useDeleteDocument(assetId); - const [file, setFile] = useState(null); - const [name, setName] = useState(""); - - const handleUpload = (e: React.FormEvent) => { - e.preventDefault(); - if (file) { - uploadMutation.mutate({ file, name: name || file.name }); - setFile(null); - setName(""); - } - }; - - return ( -
-

- Asset Documents -

-
- setFile(e.target.files?.[0] || null)} - className="border rounded px-2 py-1 text-sm" - /> - setName(e.target.value)} - className="border rounded px-2 py-1 text-sm" - /> - -
- {isLoading ? ( -
Loading documents...
- ) : documents.length === 0 ? ( -
No documents uploaded yet.
- ) : ( -
    - {documents.map((doc) => ( -
  • -
    - - {doc.name} - - {doc.type} - - {(doc.size / 1024).toFixed(1)} KB - - - Uploaded by {doc.uploadedBy?.firstName}{" "} - {doc.uploadedBy?.lastName} - - - {format(new Date(doc.createdAt), "MMM d, yyyy")} - -
    - -
  • - ))} -
- )}
); } diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx index 6e808bba..3209bdda 100644 --- a/frontend/app/(dashboard)/assets/page.tsx +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -1,327 +1,208 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Search, Plus, ChevronLeft, ChevronRight } from "lucide-react"; +import { Plus, Search, SlidersHorizontal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; -import { useAssets } from "@/lib/query/hooks/useAsset"; -import { AssetStatus } from "@/lib/query/types/asset"; +import { CreateAssetModal } from "@/components/assets/create-asset-modal"; +import { useAssets } from "@/lib/query/hooks/useAssets"; +import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; -type SortField = "assetId" | "name" | "category" | "status" | "condition" | "department" | "assignedTo"; -type SortOrder = "asc" | "desc"; +const STATUS_OPTIONS = ["All", ...Object.values(AssetStatus)]; export default function AssetsPage() { const router = useRouter(); + const [showModal, setShowModal] = useState(false); const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [sortField, setSortField] = useState("assetId"); - const [sortOrder, setSortOrder] = useState("asc"); - - const itemsPerPage = 10; - - // Debounce search - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(search); - setCurrentPage(1); // Reset to page 1 when search changes - }, 300); - - return () => clearTimeout(timer); - }, [search]); - - // Reset to page 1 when filter changes - useEffect(() => { - setCurrentPage(1); - }, [statusFilter]); - - const { data, isLoading, error } = useAssets({ - page: currentPage, - limit: itemsPerPage, - search: debouncedSearch, - status: statusFilter || undefined, - sortBy: sortField, - sortOrder, + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + + const { data, isLoading, refetch } = useAssets({ + search: search || undefined, + status: (status as AssetStatus) || undefined, + page, + limit: 20, }); - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortField(field); - setSortOrder("asc"); - } - }; - - const handleRowClick = (assetId: string) => { - router.push(`/assets/${assetId}`); - }; - - const handlePreviousPage = () => { - setCurrentPage((prev) => Math.max(1, prev - 1)); - }; - - const handleNextPage = () => { - setCurrentPage((prev) => Math.min(data?.totalPages || 1, prev + 1)); - }; - - if (error) { - return ( -
-

Error loading assets.

- -
- ); - } + const assets = data?.data ?? []; + const total = data?.total ?? 0; + const totalPages = Math.ceil(total / 20); return (
{/* Header */} -
-
-
-

Assets

-

- {data?.total || 0} total assets -

-
- +
+
+

Assets

+

+ {total > 0 + ? `${total} asset${total !== 1 ? "s" : ""} registered` + : "No assets yet"} +

+
{/* Filters */} -
-
- {/* Search */} -
-
- - setSearch(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
-
- - {/* Status Filter */} -
- -
+
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + />
-
- {/* Loading State */} - {isLoading && ( -
-
-
-

Loading assets...

-
+
+ +
- )} +
{/* Table */} - {!isLoading && data && ( -
- {data.assets.length === 0 ? ( -
-

- {debouncedSearch || statusFilter - ? "No assets found matching your filters." - : "No assets registered yet."} -

- {!debouncedSearch && !statusFilter && ( - +
+
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : assets.length === 0 ? ( + + + + ) : ( + assets.map((asset) => ( + router.push(`/assets/${asset.id}`)} + className="border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + + + )) )} + +
+ Asset ID + + Name + + Category + + Status + + Condition + + Department + + Assigned To +
+ Loading assets... +
+ {search || status + ? "No assets match your filters." + : 'No assets registered yet. Click "Register Asset" to get started.'} +
+ {asset.assetId} + + {asset.name} + + {asset.category?.name ?? "—"} + + + + + + {asset.department?.name ?? "—"} + + {asset.assignedTo ? ( + `${asset.assignedTo.name}` + ) : ( + Unassigned + )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} — {total} total +

+
+ +
- ) : ( - <> -
- - - - - - - - - - - - - - {data.assets.map((asset) => ( - handleRowClick(asset.id)} - className="hover:bg-gray-50 cursor-pointer transition-colors" - > - - - - - - - - - ))} - -
- - - - - - - - - - - - - -
- {asset.assetId} - - {asset.name} - - {asset.category?.name || "—"} - - - - - - {asset.department?.name || "—"} - - {asset.assignedTo - ? `${asset.assignedTo.name}` - : "Unassigned"} -
-
+
+ )} +
- {/* Pagination */} - {data.totalPages > 1 && ( -
-
-
- Showing {((currentPage - 1) * itemsPerPage) + 1} to{" "} - {Math.min(currentPage * itemsPerPage, data.total)} of{" "} - {data.total} results -
-
- - - Page {currentPage} of {data.totalPages} - - -
-
-
- )} - - )} -
+ {showModal && ( + setShowModal(false)} + onSuccess={() => refetch()} + /> )}
); diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 00000000..1df61765 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import Link from 'next/link'; +import { format } from 'date-fns'; +import { useAuthStore } from '@/store/auth.store'; +import { useAssets } from '@/lib/query/hooks/useAssets'; +import { StatusBadge } from '@/components/assets/status-badge'; +import { AssetStatus } from '@/lib/query/types/asset'; + +export default function DashboardPage() { + const user = useAuthStore((s) => s.user); + const { data: allAssets } = useAssets({ limit: 5 }); + const { data: activeAssets } = useAssets({ status: AssetStatus.ACTIVE, limit: 1 }); + const { data: assignedAssets } = useAssets({ status: AssetStatus.ASSIGNED, limit: 1 }); + + const total = allAssets?.total ?? 0; + const active = activeAssets?.total ?? 0; + const assigned = assignedAssets?.total ?? 0; + const recent = allAssets?.data ?? []; + + const stats = [ + { label: 'Total Assets', value: total }, + { label: 'Active', value: active }, + { label: 'Assigned', value: assigned }, + { label: 'In Maintenance', value: total - active - assigned }, + ]; + + return ( +
+
+

+ Welcome back{user ? `, ${user.firstName}` : ''} +

+

Here's an overview of your assets

+
+ + {/* Stat cards */} +
+ {stats.map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* Recent assets */} +
+
+

Recent Assets

+ + View all + +
+ + {recent.length === 0 ? ( +

+ No assets yet.{' '} + Register your first asset +

+ ) : ( +
+ {recent.map((asset) => ( + +
+

{asset.name}

+

+ {asset.assetId} · {asset.department?.name} · {format(new Date(asset.createdAt), 'MMM d')} +

+
+ + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/departments/page.tsx b/frontend/app/(dashboard)/departments/page.tsx index 20e9951a..9e0f9bbf 100644 --- a/frontend/app/(dashboard)/departments/page.tsx +++ b/frontend/app/(dashboard)/departments/page.tsx @@ -1,204 +1,334 @@ 'use client'; -import React, { useState } from 'react'; +import { useState } from 'react'; +import { Plus, Trash2, Building2, Tag, Package } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { - useDepartmentsList, - useCreateDepartment, - useDeleteDepartment, - useCategories, - useCreateCategory, - useDeleteCategory -} from '@/lib/query/hooks/query.hook'; + useDepartmentsList, + useCreateDepartment, + useDeleteDepartment, + useCategories, + useCreateCategory, + useDeleteCategory, +} from '@/lib/query/hooks/useAssets'; import { DepartmentWithCount, CategoryWithCount } from '@/lib/api/assets'; -import { Plus, Trash2, LayoutGrid, Tags, Loader2, AlertCircle } from 'lucide-react'; -import { toast } from 'react-toastify'; -type TabType = 'departments' | 'categories'; +type Tab = 'departments' | 'categories'; export default function DepartmentsPage() { - const [activeTab, setActiveTab] = useState('departments'); - const [isAdding, setIsAdding] = useState(false); - const [formData, setFormData] = useState({ name: '', description: '' }); - - const { data: departments, isLoading: isLoadingDepts } = useDepartmentsList(); - const { data: categories, isLoading: isLoadingCats } = useCategories(); - - const createDept = useCreateDepartment(); - const deleteDept = useDeleteDepartment(); - const createCat = useCreateCategory(); - const deleteCat = useDeleteCategory(); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!formData.name.trim()) return; - - try { - if (activeTab === 'departments') { - await createDept.mutateAsync(formData); - } else { - await createCat.mutateAsync(formData); - } - toast.success(`${activeTab === 'departments' ? 'Department' : 'Category'} created successfully`); - setFormData({ name: '', description: '' }); - setIsAdding(false); - } catch (err: any) { - toast.error(err.message || `Failed to create ${activeTab}`); - } - }; - - const handleDelete = async (id: string, name: string) => { - const confirmMessage = `Are you sure you want to delete ${name}? Assets in this ${activeTab === 'departments' ? 'department' : 'category'} will need to be reassigned/recategorised.`; - if (!window.confirm(confirmMessage)) return; - - try { - if (activeTab === 'departments') { - await deleteDept.mutateAsync(id); - } else { - await deleteCat.mutateAsync(id); - } - toast.success(`${activeTab === 'departments' ? 'Department' : 'Category'} deleted successfully`); - } catch (err: any) { - toast.error(err.message || `Failed to delete ${activeTab}`); - } - }; - - const items = activeTab === 'departments' ? departments : categories; - const isLoading = activeTab === 'departments' ? isLoadingDepts : isLoadingCats; - - return ( -
-
-

Management

- -
- - -
+ const [tab, setTab] = useState('departments'); + + return ( +
+ {/* Header */} +
+

Organisation

+

Manage departments and asset categories

+
+ + {/* Tabs */} +
+ {([ + { key: 'departments' as Tab, label: 'Departments', icon: }, + { key: 'categories' as Tab, label: 'Categories', icon: }, + ] as const).map(({ key, label, icon }) => ( + + ))} +
+ + {tab === 'departments' && } + {tab === 'categories' && } +
+ ); +} + +// ── Departments Tab ────────────────────────────────────────── + +function DepartmentsTab() { + const { data: departments = [], isLoading } = useDepartmentsList(); + const createDept = useCreateDepartment(); + const deleteDept = useDeleteDepartment(); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + const handleCreate = async () => { + if (!name.trim()) { setFormError('Name is required'); return; } + setFormError(''); + try { + await createDept.mutateAsync({ name: name.trim(), description: description.trim() || undefined }); + setName(''); + setDescription(''); + setShowForm(false); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setFormError(msg || 'Failed to create department.'); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteDept.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+

+ {departments.length} department{departments.length !== 1 ? 's' : ''} +

+ +
+ + {/* Inline create form */} + {showForm && ( +
+

New Department

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + setDescription(e.target.value)} + /> + {formError &&

{formError}

} +
+ +
+
+
+ )} + + {/* List */} + {isLoading ? ( +
Loading departments...
+ ) : departments.length === 0 ? ( + } + title="No departments yet" + message='Click "Add Department" to create your first one.' + /> + ) : ( +
+ {departments.map((dept) => ( + setDeleteTarget(dept)} + /> + ))} +
+ )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteDept.isPending} + /> + )} +
+ ); +} + +// ── Categories Tab ─────────────────────────────────────────── + +function CategoriesTab() { + const { data: categories = [], isLoading } = useCategories(); + const createCat = useCreateCategory(); + const deleteCat = useDeleteCategory(); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); -
-

- {activeTab} ({items?.length || 0}) -

- + const handleCreate = async () => { + if (!name.trim()) { setFormError('Name is required'); return; } + setFormError(''); + try { + await createCat.mutateAsync({ name: name.trim(), description: description.trim() || undefined }); + setName(''); + setDescription(''); + setShowForm(false); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setFormError(msg || 'Failed to create category.'); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteCat.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+

+ {categories.length} categor{categories.length !== 1 ? 'ies' : 'y'} +

+ +
+ + {/* Inline create form */} + {showForm && ( +
+

New Category

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + setDescription(e.target.value)} + /> + {formError &&

{formError}

} +
+ +
+
+
+ )} - {isAdding && ( -
-
-
- - setFormData({ ...formData, name: e.target.value })} - className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - /> - {activeTab === 'departments' ? createDept.isError && ( -

- {createDept.error?.message} -

- ) : createCat.isError && ( -

- {createCat.error?.message} -

- )} -
-
- - setFormData({ ...formData, description: e.target.value })} - className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - /> -
-
-
- - -
-
- )} - - {isLoading ? ( -
- -

Loading {activeTab}...

-
- ) : ( -
- {items?.map((item: any) => ( -
-
-

{item.name}

- -
-

- {item.description || 'No description provided.'} -

-
- Asset Count - {item.assetCount || 0} -
-
- ))} - {!isLoading && items?.length === 0 && ( -
- -

No {activeTab} found. Create one to get started.

-
- )} -
- )} + {/* List */} + {isLoading ? ( +
Loading categories...
+ ) : categories.length === 0 ? ( + } + title="No categories yet" + message='Click "Add Category" to create your first one.' + /> + ) : ( +
+ {categories.map((cat) => ( + setDeleteTarget(cat)} + /> + ))}
- ); + )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteCat.isPending} + /> + )} +
+ ); +} + +// ── Shared components ──────────────────────────────────────── + +function EntityCard({ + name, + description, + count, + countLabel, + onDelete, +}: { + name: string; + description?: string | null; + count: number; + countLabel: string; + onDelete: () => void; +}) { + return ( +
+
+

{name}

+ {description && ( +

{description}

+ )} +
+ + {count} {countLabel}{count !== 1 ? 's' : ''} +
+
+ +
+ ); +} + +function EmptyState({ icon, title, message }: { icon: React.ReactNode; title: string; message: string }) { + return ( +
+
{icon}
+

{title}

+

{message}

+
+ ); } diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..8e45bb04 --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,19 @@ +// frontend/app/(dashboard)/layout.tsx +import { Sidebar } from "@/components/layout/sidebar"; +import { Topbar } from "@/components/layout/topbar"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + +
+
{children}
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/reports/page.tsx b/frontend/app/(dashboard)/reports/page.tsx index 108334d8..27f9f341 100644 --- a/frontend/app/(dashboard)/reports/page.tsx +++ b/frontend/app/(dashboard)/reports/page.tsx @@ -5,8 +5,8 @@ import Link from "next/link"; import { format } from "date-fns"; import { BarChart3, Package } from "lucide-react"; import { clsx } from "clsx"; -import { useAssets } from "@/lib/query/hooks/useAsset"; -import { Asset, AssetStatus } from "@/lib/query/types/asset"; +import { useReportsSummary } from "@/lib/query/hooks/useReports"; +import { AssetStatus } from "@/lib/query/types/asset"; import { StatusBadge } from "@/components/assets/status-badge"; const STATUS_COLORS: Record = { @@ -17,7 +17,7 @@ const STATUS_COLORS: Record = { }; export default function ReportsPage() { - const { data, isLoading } = useAssets({ page: 1, limit: 1000 }); + const { data, isLoading } = useReportsSummary(); if (isLoading) { return ( @@ -29,50 +29,7 @@ export default function ReportsPage() { if (!data) return null; - const assets = data?.assets ?? []; - const total = assets.length; - - const byStatus = assets.reduce>( - (acc, asset) => { - acc[asset.status] += 1; - return acc; - }, - { - [AssetStatus.ACTIVE]: 0, - [AssetStatus.ASSIGNED]: 0, - [AssetStatus.MAINTENANCE]: 0, - [AssetStatus.RETIRED]: 0, - }, - ); - - const byCategory = Object.values( - assets.reduce>((acc, asset) => { - const categoryName = asset.category?.name ?? "Uncategorized"; - if (!acc[categoryName]) { - acc[categoryName] = { name: categoryName, count: 0 }; - } - acc[categoryName].count += 1; - return acc; - }, {}), - ); - - const byDepartment = Object.values( - assets.reduce>((acc, asset) => { - const departmentName = asset.department?.name ?? "Unassigned"; - if (!acc[departmentName]) { - acc[departmentName] = { name: departmentName, count: 0 }; - } - acc[departmentName].count += 1; - return acc; - }, {}), - ); - - const recent = [...assets] - .sort( - (left, right) => - new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), - ) - .slice(0, 10); + const { total, byStatus, byCategory, byDepartment, recent } = data; const statusItems = Object.entries(byStatus) as [AssetStatus, number][]; const topCategories = [...byCategory] @@ -230,7 +187,7 @@ export default function ReportsPage() {

) : (
- {recent.map((asset: Asset) => ( + {recent.map((asset) => ( ; export default function SettingsPage() { - const { user } = useAuthStore(); + const user = useAuthStore((s) => s.user); const updateProfile = useUpdateProfile(); const [profileSaved, setProfileSaved] = useState(false); const [passwordSaved, setPasswordSaved] = useState(false); diff --git a/frontend/app/(dashboard)/users/page.tsx b/frontend/app/(dashboard)/users/page.tsx new file mode 100644 index 00000000..c6013f4d --- /dev/null +++ b/frontend/app/(dashboard)/users/page.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Shield, UserCircle } from 'lucide-react'; +import { format } from 'date-fns'; +import { clsx } from 'clsx'; +import { useUsersList, useUpdateUserRole } from '@/lib/query/hooks/useUsers'; +import { useAuthStore } from '@/store/auth.store'; +import { AppUser, UserRole } from '@/lib/api/users'; + +const ROLES: UserRole[] = ['admin', 'manager', 'staff']; + +const roleConfig: Record = { + admin: { label: 'Admin', className: 'bg-purple-100 text-purple-700' }, + manager: { label: 'Manager', className: 'bg-blue-100 text-blue-700' }, + staff: { label: 'Staff', className: 'bg-gray-100 text-gray-600' }, +}; + +export default function UsersPage() { + const currentUser = useAuthStore((s) => s.user); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + + const { data: users = [], isLoading } = useUsersList(); + const updateRole = useUpdateUserRole(); + + // client-side filter (list is typically small) + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return users.filter((u) => { + const matchSearch = + !q || + u.firstName.toLowerCase().includes(q) || + u.lastName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q); + const matchRole = !roleFilter || u.role === roleFilter; + return matchSearch && matchRole; + }); + }, [users, search, roleFilter]); + + const handleRoleChange = (user: AppUser, newRole: UserRole) => { + if (newRole === user.role) return; + updateRole.mutate({ id: user.id, role: newRole }); + }; + + return ( +
+ {/* Header */} +
+
+

Users

+

+ {users.length} member{users.length !== 1 ? 's' : ''} in your organisation +

+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + /> +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map((user) => { + const isCurrentUser = user.id === currentUser?.id; + const initials = `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(); + + return ( + + {/* Avatar + name */} + + + {/* Email */} + + + {/* Role dropdown */} + + + {/* Joined */} + + + ); + }) + )} + +
UserEmailRoleJoined
Loading users...
+ +

+ {search || roleFilter ? 'No users match your filters.' : 'No users found.'} +

+
+
+
+ {initials} +
+
+

+ {user.firstName} {user.lastName} + {isCurrentUser && ( + (you) + )} +

+
+
+
{user.email} + handleRoleChange(user, role)} + /> + + {format(new Date(user.createdAt), 'MMM d, yyyy')} +
+ + {/* Role legend */} + {users.length > 0 && ( +
+

+ + Role permissions: +

+ {ROLES.map((r) => ( + + {roleConfig[r].label} + + ))} +
+ )} +
+
+ ); +} + +// ── Role Dropdown ──────────────────────────────────────────── + +function RoleDropdown({ + value, + disabled, + onChange, +}: { + value: UserRole; + disabled: boolean; + onChange: (role: UserRole) => void; +}) { + const config = roleConfig[value]; + + return ( +
+ + {/* Chevron icon overlay */} + + â–¾ + +
+ ); +} diff --git a/frontend/app/dashboard-layout.ts b/frontend/app/dashboard-layout.ts deleted file mode 100644 index 28c723ad..00000000 --- a/frontend/app/dashboard-layout.ts +++ /dev/null @@ -1,166 +0,0 @@ -// frontend/app/(dashboard)/layout.tsx -import { Sidebar } from '@/components/layout/sidebar'; -import { Topbar } from '@/components/layout/topbar'; - -export default function DashboardLayout({ children }: { children: React.ReactNode }) { -return ( -
- - -
-
{children}
-
-
-); -} - -// frontend/components/layout/topbar.tsx -'use client'; - -import { useRouter } from 'next/navigation'; -import { LogOut, User } from 'lucide-react'; -import { useAuthStore } from '@/store/auth.store'; - -export function Topbar() { -const router = useRouter(); -const { user, logout } = useAuthStore(); - -const handleLogout = async () => { -await logout(); -router.push('/login'); -}; - -const initials = user -? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase() -: '?'; - -return ( -
-
- -
- {/* User info */} -
-
- {user ? ( - {initials} - ) : ( - - )} -
- {user && ( -
-

- {user.firstName} {user.lastName} -

-

{user.role}

-
- )} -
- - {/* Divider */} -
- - {/* Logout */} - -
-
- -); -} - -// frontend/components/layout/sidebar.tsx -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { clsx } from "clsx"; -import { -LayoutDashboard, -Package, -Users, -Building2, -BarChart3, -Settings, -} from "lucide-react"; - -const navItems = [ -{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, -{ href: "/assets", label: "Assets", icon: Package }, -{ href: "/users", label: "Users", icon: Users }, -{ href: "/departments", label: "Organisation", icon: Building2 }, -{ href: "/reports", label: "Reports", icon: BarChart3 }, -]; - -export function Sidebar() { -const pathname = usePathname(); - -return ( - - -); -} diff --git a/frontend/app/hooks/useUsers.ts b/frontend/app/hooks/useUsers.ts deleted file mode 100644 index aadc2430..00000000 --- a/frontend/app/hooks/useUsers.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getUsers, updateUserRole, updateProfile } from '../lib/api/usersApi'; -import { getReportsSummary } from '../lib/api/reportsApi'; -import { User, ReportSummary } from '../lib/types/users'; -import { useAuthStore } from '../store/authStore'; - -export function useUsersList() { - return useQuery({ - queryKey: ['users'], - queryFn: getUsers, - }); -} - -export function useUpdateUserRole() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, role }: { id: string; role: string }) => - updateUserRole(id, role), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} - -export function useUpdateProfile() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - updateProfile(id, payload), - onSuccess: (updatedUser) => { - // Update Zustand store to keep sidebar in sync - useAuthStore.getState().setUser(updatedUser); - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} - -export function useReportsSummary() { - return useQuery({ - queryKey: ['reportsSummary'], - queryFn: getReportsSummary, - }); -} diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index 22f8b8d6..ab91c89f 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -1,8 +1,7 @@ -'use client'; +"use client"; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AuthInitializer } from '@/components/auth/AuthInitializer'; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient({ defaultOptions: { @@ -15,9 +14,6 @@ const queryClient = new QueryClient({ export function Providers({ children }: { children: React.ReactNode }) { return ( - - - {children} - + {children} ); } diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx deleted file mode 100644 index 287b41f0..00000000 --- a/frontend/app/users/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { useUsers, useUpdateUserRole } from '../hooks/useUsers'; -import { Avatar } from '../../components/ui/Avatar'; - -export default function UsersPage() { - const { data: users = [], isLoading } = useUsers(); - const updateRole = useUpdateUserRole(); - - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - - if (isLoading) return
Loading...
; - - const filteredUsers = users.filter((u: any) => - (u.name.toLowerCase().includes(search.toLowerCase()) || - u.email.toLowerCase().includes(search.toLowerCase())) && - (roleFilter ? u.role === roleFilter : true) - ); - - return ( -
-

Users Management

- -
- setSearch(e.target.value)} - className="border p-2" - /> - -
- - - - - - - - - - - - {filteredUsers.map((user: any) => ( - - - - - - - ))} - -
Avatar + NameEmailRoleJoined
- - {user.name}{' '} - {user.isCurrentUser && ( - - You - - )} - {user.email} - - {new Date(user.joinedAt).toLocaleDateString()}
- -
-

Role Legend

-
    -
  • - Admin — full access -
  • -
  • - Manager — manage teams -
  • -
  • - Staff — standard user -
  • -
-
-
- ); -} diff --git a/frontend/components/assets/condition-badge.tsx b/frontend/components/assets/condition-badge.tsx index a0a3f65f..d21773f0 100644 --- a/frontend/components/assets/condition-badge.tsx +++ b/frontend/components/assets/condition-badge.tsx @@ -1,47 +1,18 @@ -"use client"; +import { clsx } from 'clsx'; +import { AssetCondition } from '@/lib/query/types/asset'; -import { AssetCondition } from "@/lib/query/types/asset"; - -const conditionConfig = { - [AssetCondition.NEW]: { - label: "New", - className: "bg-emerald-100 text-emerald-800 border-emerald-200", - }, - [AssetCondition.GOOD]: { - label: "Good", - className: "bg-green-100 text-green-800 border-green-200", - }, - [AssetCondition.FAIR]: { - label: "Fair", - className: "bg-blue-100 text-blue-800 border-blue-200", - }, - [AssetCondition.POOR]: { - label: "Poor", - className: "bg-orange-100 text-orange-800 border-orange-200", - }, - [AssetCondition.DAMAGED]: { - label: "Damaged", - className: "bg-red-100 text-red-800 border-red-200", - }, +const conditionConfig: Record = { + [AssetCondition.NEW]: { label: 'New', className: 'bg-emerald-100 text-emerald-700' }, + [AssetCondition.GOOD]: { label: 'Good', className: 'bg-green-100 text-green-700' }, + [AssetCondition.FAIR]: { label: 'Fair', className: 'bg-yellow-100 text-yellow-700' }, + [AssetCondition.POOR]: { label: 'Poor', className: 'bg-orange-100 text-orange-700' }, + [AssetCondition.DAMAGED]: { label: 'Damaged', className: 'bg-red-100 text-red-700' }, }; -interface ConditionBadgeProps { - condition: AssetCondition; -} - -export function ConditionBadge({ condition }: ConditionBadgeProps) { - const config = conditionConfig[condition]; - - if (!config) { - return ( - - {condition} - - ); - } - +export function ConditionBadge({ condition }: { condition: AssetCondition }) { + const config = conditionConfig[condition] ?? { label: condition, className: 'bg-gray-100 text-gray-500' }; return ( - + {config.label} ); diff --git a/frontend/components/assets/create-asset-modal.tsx b/frontend/components/assets/create-asset-modal.tsx index 9f455960..d6b5c76c 100644 --- a/frontend/components/assets/create-asset-modal.tsx +++ b/frontend/components/assets/create-asset-modal.tsx @@ -1,20 +1,21 @@ -'use client'; - -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useCreateAsset } from '@/lib/query/hooks/useAssets'; -import { useDepartments } from '@/lib/query/hooks/useAsset'; -import { useCategories } from '@/lib/query/hooks/useAssets'; -import { AssetStatus, AssetCondition } from '@/lib/query/types/asset'; +// frontend/components/assets/create-asset-modal.tsx +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useCreateAsset } from "@/lib/query/hooks/useAssets"; +import { useDepartments } from "@/lib/query/hooks/useAsset"; +import { useCategories } from "@/lib/query/hooks/useAssets"; +import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; const schema = z.object({ - name: z.string().min(1, 'Asset name is required'), - categoryId: z.string().min(1, 'Category is required'), - departmentId: z.string().min(1, 'Department is required'), + name: z.string().min(1, "Asset name is required"), + categoryId: z.string().min(1, "Category is required"), + departmentId: z.string().min(1, "Department is required"), serialNumber: z.string().optional(), manufacturer: z.string().optional(), model: z.string().optional(), @@ -44,7 +45,10 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { formState: { errors }, } = useForm({ resolver: zodResolver(schema), - defaultValues: { condition: AssetCondition.NEW, status: AssetStatus.ACTIVE }, + defaultValues: { + condition: AssetCondition.NEW, + status: AssetStatus.ACTIVE, + }, }); const onSubmit = async (values: FormValues) => { @@ -59,16 +63,18 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { location: values.location || undefined, condition: values.condition, status: values.status, - purchasePrice: values.purchasePrice ? Number(values.purchasePrice) : undefined, + purchasePrice: values.purchasePrice + ? Number(values.purchasePrice) + : undefined, notes: values.notes || undefined, }); onSuccess?.(); onClose(); } catch (err: unknown) { const message = - (err as { response?: { data?: { message?: string } } })?.response?.data?.message || - 'Failed to register asset.'; - setError('root', { message }); + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || "Failed to register asset."; + setError("root", { message }); } }; @@ -80,81 +86,143 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { {/* Modal */}
-

Register New Asset

-
- +
{/* Category */}
- + - {errors.categoryId &&

{errors.categoryId.message}

} + {errors.categoryId && ( +

+ {errors.categoryId.message} +

+ )}
{/* Department */}
- + - {errors.departmentId &&

{errors.departmentId.message}

} + {errors.departmentId && ( +

+ {errors.departmentId.message} +

+ )}
- - + +
- - + +
{/* Condition */}
- +
- +
- +