diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 584803ff..00000000
--- a/package-lock.json
+++ /dev/null
@@ -1,2796 +0,0 @@
-{
- "name": "banking-app-fullstack-frontend",
- "version": "0.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "banking-app-fullstack-frontend",
- "version": "0.0.0",
- "dependencies": {
- "@tailwindcss/vite": "^4.3.3",
- "react": "^19.2.8",
- "react-dom": "^19.2.8",
- "react-router-dom": "^7.18.2",
- "tailwindcss": "^4.3.3"
- },
- "devDependencies": {
- "@eslint/js": "^10.0.1",
- "@types/react": "^19.2.18",
- "@types/react-dom": "^19.2.4",
- "@vitejs/plugin-react": "^6.1.0",
- "eslint": "^10.9.0",
- "eslint-plugin-react-hooks": "^7.1.1",
- "eslint-plugin-react-refresh": "^0.5.4",
- "globals": "^17.11.0",
- "vite": "^8.2.2"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.29.7",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-compilation-targets": "^7.29.7",
- "@babel/helper-module-transforms": "^7.29.7",
- "@babel/helpers": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.29.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.8",
- "@babel/types": "^7.29.8",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.8"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.29.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.29.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.8",
- "@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.8",
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.8",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.10.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.12.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/config-array": {
- "version": "0.23.5",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/object-schema": "^3.0.5",
- "debug": "^4.3.1",
- "minimatch": "^10.2.4"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/config-helpers": {
- "version": "0.7.0",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^1.2.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/core": {
- "version": "1.2.1",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/js": {
- "version": "10.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "eslint": "^10.0.0"
- },
- "peerDependenciesMeta": {
- "eslint": {
- "optional": true
- }
- }
- },
- "node_modules/@eslint/object-schema": {
- "version": "3.0.5",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/plugin-kit": {
- "version": "0.7.2",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^1.2.1",
- "levn": "^0.4.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@humanfs/core": {
- "version": "0.19.2",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/types": "^0.15.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/node": {
- "version": "0.16.8",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/core": "^0.19.2",
- "@humanfs/types": "^0.15.0",
- "@humanwhocodes/retry": "^0.4.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/types": {
- "version": "0.15.0",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/retry": {
- "version": "0.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.147.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
- "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
- "node_modules/@rolldown/binding-android-arm-eabi": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
- "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
- "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
- "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
- "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
- "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
- "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
- "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
- "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
- "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
- "cpu": [
- "ppc64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
- "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
- "cpu": [
- "s390x"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
- "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
- "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
- "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
- "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.6",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
- "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
- "license": "MIT"
- },
- "node_modules/@tailwindcss/node": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
- "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "^5.24.1",
- "jiti": "^2.7.0",
- "lightningcss": "1.32.0",
- "magic-string": "^0.30.21",
- "source-map-js": "^1.2.1",
- "tailwindcss": "4.3.3"
- }
- },
- "node_modules/@tailwindcss/oxide": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
- "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
- "license": "MIT",
- "engines": {
- "node": ">= 20"
- },
- "optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.3.3",
- "@tailwindcss/oxide-darwin-arm64": "4.3.3",
- "@tailwindcss/oxide-darwin-x64": "4.3.3",
- "@tailwindcss/oxide-freebsd-x64": "4.3.3",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
- "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
- "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
- }
- },
- "node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
- "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
- "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
- "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
- "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
- "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
- "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
- "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
- "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
- "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
- "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
- "bundleDependencies": [
- "@napi-rs/wasm-runtime",
- "@emnapi/core",
- "@emnapi/runtime",
- "@tybys/wasm-util",
- "@emnapi/wasi-threads",
- "tslib"
- ],
- "cpu": [
- "wasm32"
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "^1.11.1",
- "@emnapi/runtime": "^1.11.1",
- "@emnapi/wasi-threads": "^1.2.2",
- "@napi-rs/wasm-runtime": "^1.1.4",
- "@tybys/wasm-util": "^0.10.2",
- "tslib": "^2.8.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
- "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.3.3",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/vite": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
- "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
- "license": "MIT",
- "dependencies": {
- "@tailwindcss/node": "4.3.3",
- "@tailwindcss/oxide": "4.3.3",
- "tailwindcss": "4.3.3"
- },
- "peerDependencies": {
- "vite": "^5.2.0 || ^6 || ^7 || ^8"
- }
- },
- "node_modules/@types/esrecurse": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
- "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "19.2.18",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "19.2.5",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
- }
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "6.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "^1.0.1"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
- "babel-plugin-react-compiler": "^1.0.0",
- "oxc-transform-react": "^0.145.0",
- "vite": "^8.0.0"
- },
- "peerDependenciesMeta": {
- "@rolldown/plugin-babel": {
- "optional": true
- },
- "babel-plugin-react-compiler": {
- "optional": true
- },
- "oxc-transform-react": {
- "optional": true
- }
- }
- },
- "node_modules/acorn": {
- "version": "8.18.0",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "6.15.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/balanced-match": {
- "version": "4.0.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.11.19",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/brace-expansion": {
- "version": "5.0.9",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/browserslist": {
- "version": "4.28.8",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "baseline-browser-mapping": "^2.11.12",
- "caniuse-lite": "^1.0.30001809",
- "electron-to-chromium": "^1.5.402",
- "node-releases": "^2.0.53",
- "update-browserslist-db": "^1.3.0"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001810",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/cookie": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
- "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "node_modules/cross-spawn": {
- "version": "7.0.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-is": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/electron-to-chromium": {
- "version": "1.5.415",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz",
- "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/enhanced-resolve": {
- "version": "5.24.5",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
- "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.4",
- "tapable": "^2.3.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "10.9.1",
- "dev": true,
- "license": "MIT",
- "workspaces": [
- "packages/*"
- ],
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.8.0",
- "@eslint-community/regexpp": "^4.12.2",
- "@eslint/config-array": "^0.23.5",
- "@eslint/config-helpers": "^0.7.0",
- "@eslint/core": "^1.2.1",
- "@eslint/plugin-kit": "^0.7.2",
- "@humanfs/node": "^0.16.6",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@humanwhocodes/retry": "^0.4.2",
- "@types/estree": "^1.0.6",
- "ajv": "^6.14.0",
- "cross-spawn": "^7.0.6",
- "debug": "^4.3.2",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^9.1.2",
- "eslint-visitor-keys": "^5.0.1",
- "espree": "^11.2.0",
- "esquery": "^1.7.0",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^8.0.0",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.5",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "jiti": "*"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-plugin-react-hooks": {
- "version": "7.1.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.24.4",
- "@babel/parser": "^7.24.4",
- "hermes-parser": "^0.25.1",
- "zod": "^3.25.0 || ^4.0.0",
- "zod-validation-error": "^3.5.0 || ^4.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
- }
- },
- "node_modules/eslint-plugin-react-refresh": {
- "version": "0.5.5",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "eslint": "^9 || ^10"
- }
- },
- "node_modules/eslint-scope": {
- "version": "9.1.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@types/esrecurse": "^4.3.1",
- "@types/estree": "^1.0.8",
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "5.0.1",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/espree": {
- "version": "11.2.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.16.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^5.0.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.7.0",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/file-entry-cache": {
- "version": "8.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^4.0.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/find-up": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "4.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.4"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/flatted": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
- "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/globals": {
- "version": "17.11.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "license": "ISC"
- },
- "node_modules/hermes-estree": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
- "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/hermes-parser": {
- "version": "0.25.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hermes-estree": "0.25.1"
- }
- },
- "node_modules/ignore": {
- "version": "5.3.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/jiti": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
- "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
- "license": "MIT",
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/jsesc": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json5": {
- "version": "2.2.3",
- "dev": true,
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
- "cpu": [
- "x64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
- "cpu": [
- "x64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
- "cpu": [
- "arm"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
- "cpu": [
- "arm64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
- "cpu": [
- "x64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/minimatch": {
- "version": "10.2.6",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "brace-expansion": "^5.0.8"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.18",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/node-releases": {
- "version": "2.0.53",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/optionator": {
- "version": "0.9.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0",
- "word-wrap": "^1.2.5"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-limit": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
- "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.26",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.17",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/react": {
- "version": "19.2.8",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
- "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "19.2.8",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
- "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
- "license": "MIT",
- "dependencies": {
- "scheduler": "^0.27.0"
- },
- "peerDependencies": {
- "react": "^19.2.8"
- }
- },
- "node_modules/react-router": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
- "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
- "license": "MIT",
- "dependencies": {
- "cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- },
- "peerDependenciesMeta": {
- "react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/react-router-dom": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
- "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
- "license": "MIT",
- "dependencies": {
- "react-router": "7.18.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- }
- },
- "node_modules/rolldown": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
- "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
- "license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.147.0",
- "@rolldown/pluginutils": "^1.0.0"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm-eabi": "1.2.6",
- "@rolldown/binding-android-arm64": "1.2.6",
- "@rolldown/binding-darwin-arm64": "1.2.6",
- "@rolldown/binding-darwin-x64": "1.2.6",
- "@rolldown/binding-freebsd-x64": "1.2.6",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
- "@rolldown/binding-linux-arm64-gnu": "1.2.6",
- "@rolldown/binding-linux-arm64-musl": "1.2.6",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
- "@rolldown/binding-linux-s390x-gnu": "1.2.6",
- "@rolldown/binding-linux-x64-gnu": "1.2.6",
- "@rolldown/binding-linux-x64-musl": "1.2.6",
- "@rolldown/binding-openharmony-arm64": "1.2.6",
- "@rolldown/binding-win32-arm64-msvc": "1.2.6",
- "@rolldown/binding-win32-x64-msvc": "1.2.6"
- }
- },
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "6.3.1",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/set-cookie-parser": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
- "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
- "license": "MIT"
- },
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tailwindcss": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
- "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
- "license": "MIT"
- },
- "node_modules/tapable": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
- "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/update-browserslist-db": {
- "version": "1.3.1",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/vite": {
- "version": "8.2.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
- "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
- "license": "MIT",
- "dependencies": {
- "lightningcss": "^1.33.0",
- "picomatch": "^4.0.5",
- "postcss": "^8.5.26",
- "rolldown": "~1.2.4",
- "tinyglobby": "^0.2.17"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.4.0 || ^0.5.0",
- "esbuild": "^0.27.0 || ^0.28.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vite/node_modules/lightningcss": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
- "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.33.0",
- "lightningcss-darwin-arm64": "1.33.0",
- "lightningcss-darwin-x64": "1.33.0",
- "lightningcss-freebsd-x64": "1.33.0",
- "lightningcss-linux-arm-gnueabihf": "1.33.0",
- "lightningcss-linux-arm64-gnu": "1.33.0",
- "lightningcss-linux-arm64-musl": "1.33.0",
- "lightningcss-linux-x64-gnu": "1.33.0",
- "lightningcss-linux-x64-musl": "1.33.0",
- "lightningcss-win32-arm64-msvc": "1.33.0",
- "lightningcss-win32-x64-msvc": "1.33.0"
- }
- },
- "node_modules/vite/node_modules/lightningcss-android-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
- "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
- "cpu": [
- "arm64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
- "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
- "cpu": [
- "arm64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-darwin-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
- "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
- "cpu": [
- "x64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
- "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
- "cpu": [
- "x64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
- "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
- "cpu": [
- "arm"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
- "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
- "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
- "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
- "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
- "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
- "cpu": [
- "arm64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
- "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
- "cpu": [
- "x64"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/which": {
- "version": "2.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/word-wrap": {
- "version": "1.2.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/yallist": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
- "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/zod": {
- "version": "4.4.3",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
- "node_modules/zod-validation-error": {
- "version": "4.0.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18.0.0"
- },
- "peerDependencies": {
- "zod": "^3.25.0 || ^4.0.0"
- }
- }
- }
-}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/.gitignore b/workshops/fullstack-aws/projects/submission/Group3/backend/.gitignore
new file mode 100644
index 00000000..17cd2fc3
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/.gitignore
@@ -0,0 +1,4 @@
+.venv/
+__pycache__/
+*.pyc
+.pytest_cache/
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/README.md b/workshops/fullstack-aws/projects/submission/Group3/backend/README.md
new file mode 100644
index 00000000..489af4e4
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/README.md
@@ -0,0 +1,34 @@
+# NoticeBoardTracker API
+
+FastAPI backend, layered: `api` (controllers) -> `services` (business logic) -> `repositories` (DB access) -> `models` (ORM). `schemas` holds the Pydantic request/response DTOs.
+
+## Setup
+
+1. `python -m venv .venv` then activate it (`.venv\Scripts\activate` on Windows, `source .venv/bin/activate` elsewhere)
+2. `pip install -r requirements.txt`
+3. Copy `.env.example` to `.env`, fill in `DATABASE_URL` (from whoever set up Postgres) and a `JWT_SECRET_KEY`
+4. `alembic upgrade head` to create the schema
+5. `uvicorn app.main:app --reload`
+
+Interactive API docs: `http://localhost:8000/docs`
+
+## Layout
+
+- `app/models` — SQLAlchemy ORM classes, the DB shape
+- `app/schemas` — Pydantic DTOs, the API shape. All inherit `CamelModel` (`app/schemas/base.py`), so Python stays snake_case internally and JSON on the wire is camelCase automatically
+- `app/repositories` — DB queries only, no business logic
+- `app/services` — business logic; `TaskAssignmentFactory` is where a task fans out into one `task_assignments` row per trainee
+- `app/api/v1` — routers, thin HTTP layer only
+- `app/dependencies/auth.py` — JWT decode + role guards (`get_current_manager`, `get_current_trainee`)
+- `alembic/versions` — migrations, the source of truth for DB structure; don't hand-edit the shared DB, add a migration instead
+
+## Adding a migration
+
+After changing a model in `app/models/`:
+
+```
+alembic revision --autogenerate -m "describe the change"
+alembic upgrade head
+```
+
+Review the generated file before committing — autogenerate doesn't always get constraints/enums exactly right.
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic.ini b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic.ini
new file mode 100644
index 00000000..a97945fe
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic.ini
@@ -0,0 +1,38 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url =
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/env.py b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/env.py
new file mode 100644
index 00000000..7366706e
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/env.py
@@ -0,0 +1,41 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.core.config import get_settings
+from app.core.database import Base
+from app.models import * # noqa: F401,F403 - register all models on Base.metadata
+
+config = context.config
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# configparser treats "%" as interpolation syntax (URL-encoded passwords hit
+# this), so escape it before handing the URL to alembic's Config.
+config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%"))
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool
+ )
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/script.py.mako b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/script.py.mako
new file mode 100644
index 00000000..3217cf0f
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/script.py.mako
@@ -0,0 +1,23 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0001_initial_schema.py b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0001_initial_schema.py
new file mode 100644
index 00000000..02fe1b96
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0001_initial_schema.py
@@ -0,0 +1,118 @@
+"""initial schema
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-08-28
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+user_role = sa.Enum("trainee", "manager", "hr", name="user_role")
+urgency_level = sa.Enum("low", "medium", "high", "urgent", name="urgency_level")
+
+
+def upgrade() -> None:
+ op.create_table(
+ "cohorts",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text, nullable=True),
+ sa.Column("manager_id", sa.Integer, nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "users",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("email", sa.String(255), unique=True, nullable=False),
+ sa.Column("hashed_password", sa.String(255), nullable=False),
+ sa.Column("full_name", sa.String(255), nullable=False),
+ sa.Column("role", user_role, nullable=False),
+ sa.Column("cohort_id", sa.Integer, sa.ForeignKey("cohorts.id"), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+
+ op.create_foreign_key("fk_cohorts_manager_id", "cohorts", "users", ["manager_id"], ["id"])
+
+ op.create_table(
+ "tasks",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("title", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text, nullable=True),
+ sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("cohort_id", sa.Integer, sa.ForeignKey("cohorts.id"), nullable=True),
+ sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("urgency", urgency_level, nullable=False, server_default="medium"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "subtasks",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("title", sa.String(255), nullable=False),
+ sa.Column("order_index", sa.Integer, nullable=False, server_default="0"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "task_assignments",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("trainee_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("current_percentage", sa.SmallInteger, nullable=False, server_default="0"),
+ sa.Column("last_updated_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.UniqueConstraint("task_id", "trainee_id", name="uq_task_assignment"),
+ sa.CheckConstraint("current_percentage BETWEEN 0 AND 100", name="ck_task_assignment_pct"),
+ )
+
+ op.create_table(
+ "subtask_completions",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("subtask_id", sa.Integer, sa.ForeignKey("subtasks.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("trainee_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("is_completed", sa.Boolean, nullable=False, server_default=sa.false()),
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.UniqueConstraint("subtask_id", "trainee_id", name="uq_subtask_completion"),
+ )
+
+ op.create_table(
+ "progress_updates",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column(
+ "task_assignment_id",
+ sa.Integer,
+ sa.ForeignKey("task_assignments.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("percentage", sa.SmallInteger, nullable=False),
+ sa.Column("comment", sa.Text, nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.CheckConstraint("percentage BETWEEN 0 AND 100", name="ck_progress_update_pct"),
+ )
+
+ op.create_index("idx_task_assignments_task", "task_assignments", ["task_id"])
+ op.create_index("idx_task_assignments_trainee", "task_assignments", ["trainee_id"])
+ op.create_index("idx_progress_updates_assignment", "progress_updates", ["task_assignment_id", "created_at"])
+
+
+def downgrade() -> None:
+ op.drop_table("progress_updates")
+ op.drop_table("subtask_completions")
+ op.drop_table("task_assignments")
+ op.drop_table("subtasks")
+ op.drop_table("tasks")
+ op.drop_constraint("fk_cohorts_manager_id", "cohorts", type_="foreignkey")
+ op.drop_table("users")
+ op.drop_table("cohorts")
+ urgency_level.drop(op.get_bind(), checkfirst=True)
+ user_role.drop(op.get_bind(), checkfirst=True)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0002_add_manager_id_to_users.py b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0002_add_manager_id_to_users.py
new file mode 100644
index 00000000..51129137
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0002_add_manager_id_to_users.py
@@ -0,0 +1,59 @@
+"""add manager_id to users
+
+Revision ID: 0002
+Revises: 0001
+Create Date: 2026-08-29
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "0002"
+down_revision = "0001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add manager_id to users.
+ #
+ # This is nullable because manager users do not belong
+ # to another manager. Trainee users will have their
+ # manager's user.id stored here.
+ op.add_column(
+ "users",
+ sa.Column(
+ "manager_id",
+ sa.Integer(),
+ nullable=True,
+ ),
+ )
+
+ # Self-referencing foreign key:
+ #
+ # users.manager_id -> users.id
+ op.create_foreign_key(
+ "fk_users_manager_id",
+ "users",
+ "users",
+ ["manager_id"],
+ ["id"],
+ )
+
+
+def downgrade() -> None:
+ # Remove the self-referencing foreign key first.
+ op.drop_constraint(
+ "fk_users_manager_id",
+ "users",
+ type_="foreignkey",
+ )
+
+ # Then remove the column.
+ op.drop_column(
+ "users",
+ "manager_id",
+ )
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/auth.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/auth.py
new file mode 100644
index 00000000..f8349f36
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/auth.py
@@ -0,0 +1,37 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.dependencies.auth import get_current_user
+from app.models.user import User
+from app.schemas.auth import LoginRequest, ProfileUpdate, TokenResponse, UserOut
+from app.services.auth_service import AuthService
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.post("/login", response_model=TokenResponse)
+def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
+ service = AuthService(db)
+ user = service.authenticate(payload.email, payload.password)
+ if user is None:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
+ token = service.issue_token(user)
+ return TokenResponse(access_token=token, role=user.role.value, user_id=user.id)
+
+
+@router.get("/me", response_model=UserOut)
+def me(current_user: User = Depends(get_current_user)) -> User:
+ return current_user
+
+
+@router.put("/me", response_model=UserOut)
+def update_me(
+ payload: ProfileUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> User:
+ try:
+ return AuthService(db).update_profile(current_user, payload)
+ except ValueError as exc:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_cohorts.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_cohorts.py
new file mode 100644
index 00000000..8baeb8d8
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_cohorts.py
@@ -0,0 +1,158 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.dependencies.auth import get_current_manager
+from app.models.user import User
+from app.schemas.cohort import (
+ CohortCreate,
+ CohortDetailOut,
+ CohortOut,
+ CohortTaskOut,
+ CohortTraineeOut,
+)
+from app.services.cohort_service import CohortService
+
+
+router = APIRouter(
+ prefix="/manager/cohorts",
+ tags=["manager-cohorts"],
+)
+
+
+# ============================================================
+# CREATE COHORT
+# ============================================================
+
+@router.post(
+ "",
+ response_model=CohortOut,
+ status_code=status.HTTP_201_CREATED,
+)
+def create_cohort(
+ payload: CohortCreate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> CohortOut:
+
+ cohort = CohortService(db).create_cohort(
+ manager.id,
+ payload,
+ )
+
+ return CohortOut(
+ id=cohort.id,
+ name=cohort.name,
+ description=cohort.description,
+ trainee_count=0,
+ )
+
+
+# ============================================================
+# LIST COHORTS
+# ============================================================
+
+@router.get(
+ "",
+ response_model=list[CohortOut],
+)
+def list_cohorts(
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> list[CohortOut]:
+
+ cohorts = CohortService(db).list_manager_cohorts(
+ manager.id,
+ )
+
+ return [
+ CohortOut(
+ id=cohort.id,
+ name=cohort.name,
+ description=cohort.description,
+ trainee_count=len(cohort.trainees),
+ )
+ for cohort in cohorts
+ ]
+
+
+# ============================================================
+# GET COHORT
+# ============================================================
+
+@router.get(
+ "/{cohort_id}",
+ response_model=CohortDetailOut,
+)
+def get_cohort(
+ cohort_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> CohortDetailOut:
+
+ service = CohortService(db)
+
+ cohort = service.get_manager_cohort(
+ manager.id,
+ cohort_id,
+ )
+
+ if cohort is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Cohort not found",
+ )
+
+ trainees = [
+ CohortTraineeOut(
+ id=trainee.id,
+ full_name=trainee.full_name,
+ email=trainee.email,
+ current_percentage=0,
+ )
+ for trainee in cohort.trainees
+ ]
+
+ return CohortDetailOut(
+ id=cohort.id,
+ name=cohort.name,
+ description=cohort.description,
+ trainees=trainees,
+ tasks=[
+ CohortTaskOut(
+ id=task.id,
+ title=task.title,
+ description=task.description,
+ due_date=task.due_date,
+ urgency=task.urgency,
+ )
+ for task in cohort.tasks
+ ],
+ )
+
+# ============================================================
+# DELETE COHORT
+# ============================================================
+
+@router.delete(
+ "/{cohort_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+def delete_cohort(
+ cohort_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> None:
+
+ deleted = CohortService(db).delete_cohort(
+ manager.id,
+ cohort_id,
+ )
+
+ if not deleted:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Cohort not found",
+ )
+
+ return None
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_progress.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_progress.py
new file mode 100644
index 00000000..21ef9f86
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_progress.py
@@ -0,0 +1,27 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.dependencies.auth import get_current_manager
+from app.models.user import User
+from app.schemas.task import TaskProgressOut, TraineeProgressDetailOut
+from app.services.task_service import TaskService
+
+router = APIRouter(prefix="/manager/tasks", tags=["manager-progress"])
+
+
+@router.get("/{task_id}/progress", response_model=TaskProgressOut)
+def task_progress(
+ task_id: int, db: Session = Depends(get_db), manager: User = Depends(get_current_manager)
+) -> dict:
+ return TaskService(db).get_task_progress_summary(manager.id, task_id)
+
+
+@router.get("/{task_id}/progress/{trainee_id}", response_model=TraineeProgressDetailOut)
+def trainee_progress(
+ task_id: int,
+ trainee_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> dict:
+ return TaskService(db).get_trainee_progress_detail(manager.id, task_id, trainee_id)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_tasks.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_tasks.py
new file mode 100644
index 00000000..59f1b741
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_tasks.py
@@ -0,0 +1,321 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.dependencies.auth import get_current_manager
+from app.models.user import User
+from app.schemas.task import (
+ SubtaskCreate,
+ SubtaskOut,
+ SubtaskUpdate,
+ TaskCreate,
+ TaskDetailOut,
+ TaskOut,
+ TaskUpdate,
+)
+from app.services.task_service import TaskService
+
+
+router = APIRouter(
+ prefix="/manager/tasks",
+ tags=["manager-tasks"],
+)
+
+subtask_router = APIRouter(
+ prefix="/manager/subtasks",
+ tags=["manager-tasks"],
+)
+
+
+# ============================================================
+# TASK -> RESPONSE
+# ============================================================
+
+def _to_task_out(task) -> TaskOut:
+ return TaskOut(
+ id=task.id,
+ title=task.title,
+ description=task.description,
+ due_date=task.due_date,
+ urgency=task.urgency,
+
+ # Cohort information
+ cohort_id=task.cohort_id,
+ cohort=(
+ {
+ "id": task.cohort.id,
+ "name": task.cohort.name,
+ }
+ if task.cohort
+ else None
+ ),
+
+ assigned_trainee_count=len(task.assignments),
+ )
+
+
+# ============================================================
+# CREATE TASK
+# ============================================================
+
+@router.post(
+ "",
+ response_model=TaskOut,
+ status_code=status.HTTP_201_CREATED,
+)
+def create_task(
+ payload: TaskCreate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> TaskOut:
+
+ task = TaskService(db).create_task(
+ manager.id,
+ payload,
+ )
+
+ return _to_task_out(task)
+
+
+# ============================================================
+# LIST MANAGER TASKS
+# ============================================================
+
+@router.get(
+ "",
+ response_model=list[TaskOut],
+)
+def list_tasks(
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> list[TaskOut]:
+
+ tasks = TaskService(db).list_manager_tasks(
+ manager.id,
+ )
+
+ return [
+ _to_task_out(task)
+ for task in tasks
+ ]
+
+
+# ============================================================
+# GET SINGLE TASK
+# ============================================================
+
+@router.get(
+ "/{task_id}",
+ response_model=TaskDetailOut,
+)
+def get_task(
+ task_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> TaskDetailOut:
+
+ service = TaskService(db)
+
+ task = service.get_owned_task(
+ manager.id,
+ task_id,
+ )
+
+ subtasks = service.subtasks.list_by_task(
+ task_id,
+ )
+
+ return TaskDetailOut(
+ id=task.id,
+ title=task.title,
+ description=task.description,
+ due_date=task.due_date,
+ urgency=task.urgency,
+
+ # Cohort information
+ cohort_id=task.cohort_id,
+ cohort=(
+ {
+ "id": task.cohort.id,
+ "name": task.cohort.name,
+ }
+ if task.cohort
+ else None
+ ),
+
+ subtasks=[
+ SubtaskOut(
+ id=subtask.id,
+ title=subtask.title,
+ order_index=subtask.order_index,
+ )
+ for subtask in subtasks
+ ],
+ )
+
+
+# ============================================================
+# UPDATE TASK
+# ============================================================
+
+@router.put(
+ "/{task_id}",
+ response_model=TaskOut,
+)
+def update_task(
+ task_id: int,
+ payload: TaskUpdate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> TaskOut:
+
+ task = TaskService(db).update_task(
+ manager.id,
+ task_id,
+ payload,
+ )
+
+ return _to_task_out(task)
+
+
+# ============================================================
+# DELETE TASK
+# ============================================================
+
+@router.delete(
+ "/{task_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+def delete_task(
+ task_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> None:
+
+ TaskService(db).delete_task(
+ manager.id,
+ task_id,
+ )
+
+
+# ============================================================
+# ADD SUBTASK
+# ============================================================
+
+@router.post(
+ "/{task_id}/subtasks",
+ response_model=SubtaskOut,
+ status_code=status.HTTP_201_CREATED,
+)
+def add_subtask(
+ task_id: int,
+ payload: SubtaskCreate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> SubtaskOut:
+
+ subtask = TaskService(db).add_subtask(
+ manager.id,
+ task_id,
+ payload.title,
+ payload.order_index,
+ )
+
+ return SubtaskOut(
+ id=subtask.id,
+ title=subtask.title,
+ order_index=subtask.order_index,
+ )
+
+
+# ============================================================
+# UPDATE SUBTASK
+# ============================================================
+
+@subtask_router.put(
+ "/{subtask_id}",
+ response_model=SubtaskOut,
+)
+def update_subtask(
+ subtask_id: int,
+ payload: SubtaskUpdate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> SubtaskOut:
+
+ service = TaskService(db)
+
+ subtask = service.subtasks.get_by_id(
+ subtask_id,
+ )
+
+ if subtask is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Subtask not found",
+ )
+
+ # Make sure the manager owns the task this
+ # subtask belongs to.
+ service.get_owned_task(
+ manager.id,
+ subtask.task_id,
+ )
+
+ for field, value in payload.model_dump(
+ exclude_unset=True,
+ ).items():
+ setattr(
+ subtask,
+ field,
+ value,
+ )
+
+ db.commit()
+ db.refresh(subtask)
+
+ return SubtaskOut(
+ id=subtask.id,
+ title=subtask.title,
+ order_index=subtask.order_index,
+ )
+
+
+# ============================================================
+# DELETE SUBTASK
+# ============================================================
+
+@subtask_router.delete(
+ "/{subtask_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+def delete_subtask(
+ subtask_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> None:
+
+ service = TaskService(db)
+
+ subtask = service.subtasks.get_by_id(
+ subtask_id,
+ )
+
+ if subtask is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Subtask not found",
+ )
+
+ # Make sure the manager owns the task this
+ # subtask belongs to.
+ service.get_owned_task(
+ manager.id,
+ subtask.task_id,
+ )
+
+ service.subtasks.delete(
+ subtask,
+ )
+
+ db.commit()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_trainees.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_trainees.py
new file mode 100644
index 00000000..4c769e3b
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_trainees.py
@@ -0,0 +1,206 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.dependencies.auth import get_current_manager
+from app.models.enums import UserRole
+from app.models.user import User
+from app.schemas.trainee import (
+ TraineeCreate,
+ TraineeListItem,
+ TraineeOut,
+ TraineeUpdate,
+ TraineeTaskOut,
+ TraineeCohortOut,
+)
+from app.services.trainee_service import TraineeService
+
+
+router = APIRouter(
+ prefix="/manager/trainees",
+ tags=["manager-trainees"],
+)
+
+
+# ============================================================
+# HELPERS
+# ============================================================
+
+def _to_trainee_list_item(trainee: User) -> TraineeListItem:
+ return TraineeListItem(
+ id=trainee.id,
+ full_name=trainee.full_name,
+ email=trainee.email,
+ cohort=(
+ TraineeCohortOut(
+ id=trainee.cohort.id,
+ name=trainee.cohort.name,
+ )
+ if trainee.cohort
+ else None
+ ),
+ )
+
+
+def _to_trainee_out(trainee: User) -> TraineeOut:
+ tasks = []
+
+ for assignment in trainee.task_assignments:
+ task = assignment.task
+
+ tasks.append(
+ TraineeTaskOut(
+ id=task.id,
+ title=task.title,
+ description=task.description,
+ due_date=task.due_date,
+ urgency=task.urgency,
+ current_percentage=assignment.current_percentage,
+ )
+ )
+
+ return TraineeOut(
+ id=trainee.id,
+ full_name=trainee.full_name,
+ email=trainee.email,
+ cohort=(
+ TraineeCohortOut(
+ id=trainee.cohort.id,
+ name=trainee.cohort.name,
+ )
+ if trainee.cohort
+ else None
+ ),
+ tasks=tasks,
+ )
+
+
+# ============================================================
+# LIST TRAINEES
+# ============================================================
+
+@router.get(
+ "",
+ response_model=list[TraineeListItem],
+)
+def list_trainees(
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> list[TraineeListItem]:
+
+ trainees = TraineeService(db).list_manager_trainees(
+ manager.id,
+ )
+
+ return [
+ _to_trainee_list_item(trainee)
+ for trainee in trainees
+ ]
+
+
+# ============================================================
+# GET TRAINEE
+# ============================================================
+
+@router.get(
+ "/{trainee_id}",
+ response_model=TraineeOut,
+)
+def get_trainee(
+ trainee_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> TraineeOut:
+
+ trainee = TraineeService(db).get_manager_trainee(
+ manager.id,
+ trainee_id,
+ )
+
+ if trainee is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Trainee not found",
+ )
+
+ return _to_trainee_out(trainee)
+
+
+# ============================================================
+# CREATE TRAINEE
+# ============================================================
+
+@router.post(
+ "",
+ response_model=TraineeOut,
+ status_code=status.HTTP_201_CREATED,
+)
+def create_trainee(
+ payload: TraineeCreate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> TraineeOut:
+
+ trainee = TraineeService(db).create_trainee(
+ manager.id,
+ payload,
+ )
+
+ return _to_trainee_out(trainee)
+
+
+# ============================================================
+# UPDATE TRAINEE
+# ============================================================
+
+@router.put(
+ "/{trainee_id}",
+ response_model=TraineeOut,
+)
+def update_trainee(
+ trainee_id: int,
+ payload: TraineeUpdate,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> TraineeOut:
+
+ trainee = TraineeService(db).update_trainee(
+ manager.id,
+ trainee_id,
+ payload,
+ )
+
+ if trainee is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Trainee not found",
+ )
+
+ return _to_trainee_out(trainee)
+
+# ============================================================
+# DELETE TRAINEE
+# ============================================================
+
+@router.delete(
+ "/{trainee_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+def delete_trainee(
+ trainee_id: int,
+ db: Session = Depends(get_db),
+ manager: User = Depends(get_current_manager),
+) -> None:
+
+ deleted = TraineeService(db).delete_trainee(
+ manager.id,
+ trainee_id,
+ )
+
+ if not deleted:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Trainee not found",
+ )
+
+ return None
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/router.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/router.py
new file mode 100644
index 00000000..984430ea
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/router.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter
+
+from app.api.v1 import (
+ auth,
+ manager_cohorts,
+ manager_progress,
+ manager_tasks,
+ manager_trainees,
+ student_tasks,
+)
+
+api_router = APIRouter(prefix="/api")
+
+api_router.include_router(auth.router)
+api_router.include_router(manager_cohorts.router)
+api_router.include_router(manager_trainees.router)
+api_router.include_router(manager_tasks.router)
+api_router.include_router(manager_tasks.subtask_router)
+api_router.include_router(manager_progress.router)
+api_router.include_router(student_tasks.router)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/student_tasks.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/student_tasks.py
new file mode 100644
index 00000000..658fe8f7
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/student_tasks.py
@@ -0,0 +1,110 @@
+from fastapi import APIRouter, Depends, Query, status
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.dependencies.auth import get_current_trainee
+from app.models.user import User
+from app.schemas.progress import (
+ ProgressUpdateCreate,
+ ProgressUpdateOut,
+ StudentTaskDetail,
+ StudentTaskListItem,
+ SubtaskToggle,
+ SubtaskToggleOut,
+)
+from app.services.progress_service import ProgressService
+
+
+# ============================================================
+# STUDENT / TRAINEE ENDPOINTS
+#
+# Base prefix: /student
+#
+# GET /student/tasks
+# Get all tasks assigned to the current trainee.
+#
+# GET /student/tasks/{task_id}
+# Get details for a specific task.
+#
+# PATCH /student/subtasks/{subtask_id}
+# Toggle a subtask's completion status.
+#
+# POST /student/tasks/{task_id}/progress
+# Submit a progress update for a task.
+#
+# GET /student/tasks/{task_id}/progress/history
+# Get the progress history for a task.
+#
+# NOTE:
+# The application may add an additional prefix such as
+# /api/v1 when this router is included in main.py.
+#
+# Example:
+# /api/v1/student/tasks
+# ============================================================
+
+router = APIRouter(prefix="/student", tags=["student"])
+
+
+@router.get("/tasks", response_model=list[StudentTaskListItem])
+def list_tasks(
+ sort: str | None = Query(default=None),
+ db: Session = Depends(get_db),
+ trainee: User = Depends(get_current_trainee),
+) -> list[dict]:
+ return ProgressService(db).list_student_tasks(trainee.id, sort)
+
+
+@router.get("/tasks/{task_id}", response_model=StudentTaskDetail)
+def task_detail(
+ task_id: int,
+ db: Session = Depends(get_db),
+ trainee: User = Depends(get_current_trainee),
+) -> dict:
+ return ProgressService(db).get_student_task_detail(trainee.id, task_id)
+
+
+@router.patch("/subtasks/{subtask_id}", response_model=SubtaskToggleOut)
+def toggle_subtask(
+ subtask_id: int,
+ payload: SubtaskToggle,
+ db: Session = Depends(get_db),
+ trainee: User = Depends(get_current_trainee),
+) -> dict:
+ return ProgressService(db).toggle_subtask(
+ trainee.id,
+ subtask_id,
+ payload.is_completed,
+ )
+
+
+@router.post(
+ "/tasks/{task_id}/progress",
+ response_model=ProgressUpdateOut,
+ status_code=status.HTTP_201_CREATED,
+)
+def submit_progress(
+ task_id: int,
+ payload: ProgressUpdateCreate,
+ db: Session = Depends(get_db),
+ trainee: User = Depends(get_current_trainee),
+) -> dict:
+ return ProgressService(db).submit_progress_update(
+ trainee.id,
+ task_id,
+ payload.percentage,
+ payload.comment,
+ )
+
+
+@router.get(
+ "/tasks/{task_id}/progress/history",
+ response_model=list[ProgressUpdateOut],
+)
+def progress_history(
+ task_id: int,
+ db: Session = Depends(get_db),
+ trainee: User = Depends(get_current_trainee),
+) -> list[ProgressUpdateOut]:
+ history = ProgressService(db).get_progress_history(trainee.id, task_id)
+ return [ProgressUpdateOut(task_id=task_id, **entry) for entry in history]
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/config.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/config.py
new file mode 100644
index 00000000..16ef9eb6
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/config.py
@@ -0,0 +1,18 @@
+from functools import lru_cache
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ database_url: str
+ jwt_secret_key: str
+ jwt_algorithm: str = "HS256"
+ access_token_expire_minutes: int = 60
+ cors_origins: list[str] = ["http://localhost:5173"]
+
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/database.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/database.py
new file mode 100644
index 00000000..33ab17bd
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/database.py
@@ -0,0 +1,21 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import declarative_base, sessionmaker
+
+from app.core.config import get_settings
+
+settings = get_settings()
+
+# One engine, one session factory for the whole process - the Singleton
+# the rest of the app depends on for DB access.
+engine = create_engine(settings.database_url, pool_pre_ping=True)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/security.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/security.py
new file mode 100644
index 00000000..43d1f03f
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/security.py
@@ -0,0 +1,30 @@
+from datetime import datetime, timedelta, timezone
+
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+
+from app.core.config import get_settings
+
+settings = get_settings()
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ return pwd_context.verify(plain_password, hashed_password)
+
+
+def create_access_token(subject: int, role: str) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
+ payload = {"sub": str(subject), "role": role, "exp": expire}
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
+
+
+def decode_access_token(token: str) -> dict:
+ try:
+ return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
+ except JWTError as exc:
+ raise ValueError("invalid token") from exc
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/auth.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/auth.py
new file mode 100644
index 00000000..0392dd2c
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/auth.py
@@ -0,0 +1,38 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.security import decode_access_token
+from app.models.enums import UserRole
+from app.models.user import User
+from app.repositories.user_repository import UserRepository
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+
+
+def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
+ credentials_error = HTTPException(status.HTTP_401_UNAUTHORIZED, "Could not validate credentials")
+ try:
+ payload = decode_access_token(token)
+ user_id = int(payload["sub"])
+ except (ValueError, KeyError):
+ raise credentials_error
+
+ user = UserRepository(db).get_by_id(user_id)
+ if user is None:
+ raise credentials_error
+ return user
+
+
+def require_role(*roles: UserRole):
+ def dependency(user: User = Depends(get_current_user)) -> User:
+ if user.role not in roles:
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Not permitted for this role")
+ return user
+
+ return dependency
+
+
+get_current_manager = require_role(UserRole.manager)
+get_current_trainee = require_role(UserRole.trainee)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/main.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/main.py
new file mode 100644
index 00000000..686c4252
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/main.py
@@ -0,0 +1,24 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.api.v1.router import api_router
+from app.core.config import get_settings
+
+settings = get_settings()
+
+app = FastAPI(title="NoticeBoardTracker API")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(api_router)
+
+
+@app.get("/health")
+def health() -> dict:
+ return {"status": "ok"}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/__init__.py
new file mode 100644
index 00000000..184a50d6
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/__init__.py
@@ -0,0 +1,17 @@
+from app.models.cohort import Cohort
+from app.models.progress_update import ProgressUpdate
+from app.models.subtask import Subtask
+from app.models.subtask_completion import SubtaskCompletion
+from app.models.task import Task
+from app.models.task_assignment import TaskAssignment
+from app.models.user import User
+
+__all__ = [
+ "User",
+ "Cohort",
+ "Task",
+ "Subtask",
+ "TaskAssignment",
+ "SubtaskCompletion",
+ "ProgressUpdate",
+]
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/cohort.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/cohort.py
new file mode 100644
index 00000000..e6324fe7
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/cohort.py
@@ -0,0 +1,69 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class Cohort(Base):
+ __tablename__ = "cohorts"
+
+ id: Mapped[int] = mapped_column(
+ primary_key=True,
+ )
+
+ name: Mapped[str] = mapped_column(
+ String(255),
+ nullable=False,
+ )
+
+ description: Mapped[str | None] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
+ # ========================================================
+ # MANAGER
+ # ========================================================
+
+ manager_id: Mapped[int | None] = mapped_column(
+ ForeignKey(
+ "users.id",
+ ondelete="SET NULL",
+ ),
+ nullable=True,
+ )
+
+ # ========================================================
+ # TIMESTAMPS
+ # ========================================================
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ )
+
+ # ========================================================
+ # TRAINEES
+ # ========================================================
+
+ # Deleting a cohort does NOT delete its trainees.
+ # Their users.cohort_id is set to NULL.
+ trainees = relationship(
+ "User",
+ foreign_keys="User.cohort_id",
+ back_populates="cohort",
+ )
+
+ # ========================================================
+ # TASKS
+ # ========================================================
+
+ # Deleting a cohort does NOT delete its tasks.
+ # Their tasks.cohort_id should be set to NULL.
+ tasks = relationship(
+ "Task",
+ foreign_keys="Task.cohort_id",
+ back_populates="cohort",
+ )
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/enums.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/enums.py
new file mode 100644
index 00000000..f219a65a
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/enums.py
@@ -0,0 +1,14 @@
+import enum
+
+
+class UserRole(str, enum.Enum):
+ trainee = "trainee"
+ manager = "manager"
+ hr = "hr"
+
+
+class UrgencyLevel(str, enum.Enum):
+ low = "low"
+ medium = "medium"
+ high = "high"
+ urgent = "urgent"
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/progress_update.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/progress_update.py
new file mode 100644
index 00000000..e0856587
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/progress_update.py
@@ -0,0 +1,23 @@
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, DateTime, ForeignKey, SmallInteger, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class ProgressUpdate(Base):
+ """Append-only history log - a new row per trainee update, never an overwrite."""
+
+ __tablename__ = "progress_updates"
+ __table_args__ = (CheckConstraint("percentage BETWEEN 0 AND 100", name="ck_progress_update_pct"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ task_assignment_id: Mapped[int] = mapped_column(
+ ForeignKey("task_assignments.id", ondelete="CASCADE"), nullable=False
+ )
+ percentage: Mapped[int] = mapped_column(SmallInteger, nullable=False)
+ comment: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ assignment = relationship("TaskAssignment", back_populates="history")
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask.py
new file mode 100644
index 00000000..b734dc12
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask.py
@@ -0,0 +1,18 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class Subtask(Base):
+ __tablename__ = "subtasks"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ task = relationship("Task", back_populates="subtasks")
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask_completion.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask_completion.py
new file mode 100644
index 00000000..b4735bf3
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask_completion.py
@@ -0,0 +1,20 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class SubtaskCompletion(Base):
+ __tablename__ = "subtask_completions"
+ __table_args__ = (UniqueConstraint("subtask_id", "trainee_id", name="uq_subtask_completion"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ subtask_id: Mapped[int] = mapped_column(ForeignKey("subtasks.id", ondelete="CASCADE"), nullable=False)
+ trainee_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
+ is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ subtask = relationship("Subtask")
+ trainee = relationship("User")
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task.py
new file mode 100644
index 00000000..d6ad4c99
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task.py
@@ -0,0 +1,72 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+from app.models.enums import UrgencyLevel
+
+
+class Task(Base):
+ __tablename__ = "tasks"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ title: Mapped[str] = mapped_column(
+ String(255),
+ nullable=False,
+ )
+
+ description: Mapped[str | None] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
+ created_by: Mapped[int] = mapped_column(
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+
+ cohort_id: Mapped[int | None] = mapped_column(
+ ForeignKey("cohorts.id"),
+ nullable=True,
+ )
+
+ due_date: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ urgency: Mapped[UrgencyLevel] = mapped_column(
+ Enum(UrgencyLevel, name="urgency_level"),
+ default=UrgencyLevel.medium,
+ nullable=False,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ )
+
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ onupdate=func.now(),
+ )
+
+ cohort = relationship(
+ "Cohort",
+ back_populates="tasks",
+ )
+
+ subtasks = relationship(
+ "Subtask",
+ back_populates="task",
+ cascade="all, delete-orphan",
+ )
+
+ assignments = relationship(
+ "TaskAssignment",
+ back_populates="task",
+ cascade="all, delete-orphan",
+ )
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task_assignment.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task_assignment.py
new file mode 100644
index 00000000..a6530b10
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task_assignment.py
@@ -0,0 +1,80 @@
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, DateTime, ForeignKey, SmallInteger, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class TaskAssignment(Base):
+ """Per-trainee anchor for a task - a cohort task fans out into one row per trainee here."""
+
+ __tablename__ = "task_assignments"
+
+ __table_args__ = (
+ UniqueConstraint(
+ "task_id",
+ "trainee_id",
+ name="uq_task_assignment",
+ ),
+ CheckConstraint(
+ "current_percentage BETWEEN 0 AND 100",
+ name="ck_task_assignment_pct",
+ ),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ task_id: Mapped[int] = mapped_column(
+ ForeignKey("tasks.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+
+ trainee_id: Mapped[int] = mapped_column(
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+
+ current_percentage: Mapped[int] = mapped_column(
+ SmallInteger,
+ default=0,
+ nullable=False,
+ )
+
+ last_updated_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ )
+
+ # ========================================================
+ # TASK
+ # ========================================================
+
+ task = relationship(
+ "Task",
+ back_populates="assignments",
+ )
+
+ # ========================================================
+ # TRAINEE
+ # ========================================================
+
+ trainee = relationship(
+ "User",
+ back_populates="task_assignments",
+ )
+
+ # ========================================================
+ # PROGRESS HISTORY
+ # ========================================================
+
+ history = relationship(
+ "ProgressUpdate",
+ back_populates="assignment",
+ cascade="all, delete-orphan",
+ )
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/user.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/user.py
new file mode 100644
index 00000000..bcf465da
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/user.py
@@ -0,0 +1,96 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Enum, ForeignKey, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+from app.models.enums import UserRole
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ email: Mapped[str] = mapped_column(
+ String(255),
+ unique=True,
+ nullable=False,
+ )
+
+ hashed_password: Mapped[str] = mapped_column(
+ String(255),
+ nullable=False,
+ )
+
+ full_name: Mapped[str] = mapped_column(
+ String(255),
+ nullable=False,
+ )
+
+ role: Mapped[UserRole] = mapped_column(
+ Enum(UserRole, name="user_role"),
+ nullable=False,
+ )
+
+ # ========================================================
+ # MANAGER
+ # ========================================================
+
+ manager_id: Mapped[int | None] = mapped_column(
+ ForeignKey("users.id"),
+ nullable=True,
+ )
+
+ manager = relationship(
+ "User",
+ remote_side=[id],
+ foreign_keys=[manager_id],
+ back_populates="managed_trainees",
+ )
+
+ managed_trainees = relationship(
+ "User",
+ foreign_keys=[manager_id],
+ back_populates="manager",
+ )
+
+ # ========================================================
+ # COHORT
+ # ========================================================
+
+ cohort_id: Mapped[int | None] = mapped_column(
+ ForeignKey("cohorts.id"),
+ nullable=True,
+ )
+
+ cohort = relationship(
+ "Cohort",
+ foreign_keys=[cohort_id],
+ back_populates="trainees",
+ )
+
+ # ========================================================
+ # TIMESTAMPS
+ # ========================================================
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ )
+
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ onupdate=func.now(),
+ )
+
+ # ========================================================
+ # TASK ASSIGNMENTS
+ # ========================================================
+
+ task_assignments = relationship(
+ "TaskAssignment",
+ back_populates="trainee",
+ cascade="all, delete-orphan",
+ )
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/progress_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/progress_repository.py
new file mode 100644
index 00000000..9315e932
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/progress_repository.py
@@ -0,0 +1,52 @@
+from datetime import datetime, timezone
+
+from sqlalchemy.orm import Session
+
+from app.models.progress_update import ProgressUpdate
+from app.models.subtask_completion import SubtaskCompletion
+
+
+class ProgressRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def add_history_entry(self, task_assignment_id: int, percentage: int, comment: str | None) -> ProgressUpdate:
+ entry = ProgressUpdate(task_assignment_id=task_assignment_id, percentage=percentage, comment=comment)
+ self.db.add(entry)
+ self.db.flush()
+ return entry
+
+ def list_history(self, task_assignment_id: int) -> list[ProgressUpdate]:
+ return (
+ self.db.query(ProgressUpdate)
+ .filter(ProgressUpdate.task_assignment_id == task_assignment_id)
+ .order_by(ProgressUpdate.created_at.desc())
+ .all()
+ )
+
+ def get_completion(self, subtask_id: int, trainee_id: int) -> SubtaskCompletion | None:
+ return (
+ self.db.query(SubtaskCompletion)
+ .filter(SubtaskCompletion.subtask_id == subtask_id, SubtaskCompletion.trainee_id == trainee_id)
+ .first()
+ )
+
+ def set_completion(self, subtask_id: int, trainee_id: int, is_completed: bool) -> SubtaskCompletion:
+ completion = self.get_completion(subtask_id, trainee_id)
+ if completion is None:
+ completion = SubtaskCompletion(subtask_id=subtask_id, trainee_id=trainee_id)
+ self.db.add(completion)
+ completion.is_completed = is_completed
+ completion.completed_at = datetime.now(timezone.utc) if is_completed else None
+ self.db.flush()
+ return completion
+
+ def list_completions_by_task(self, subtask_ids: list[int], trainee_id: int) -> dict[int, bool]:
+ if not subtask_ids:
+ return {}
+ rows = (
+ self.db.query(SubtaskCompletion)
+ .filter(SubtaskCompletion.subtask_id.in_(subtask_ids), SubtaskCompletion.trainee_id == trainee_id)
+ .all()
+ )
+ return {row.subtask_id: row.is_completed for row in rows}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/subtask_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/subtask_repository.py
new file mode 100644
index 00000000..6ff78445
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/subtask_repository.py
@@ -0,0 +1,27 @@
+from sqlalchemy.orm import Session
+
+from app.models.subtask import Subtask
+
+
+class SubtaskRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create(self, subtask: Subtask) -> Subtask:
+ self.db.add(subtask)
+ self.db.flush()
+ return subtask
+
+ def get_by_id(self, subtask_id: int) -> Subtask | None:
+ return self.db.get(Subtask, subtask_id)
+
+ def list_by_task(self, task_id: int) -> list[Subtask]:
+ return (
+ self.db.query(Subtask)
+ .filter(Subtask.task_id == task_id)
+ .order_by(Subtask.order_index)
+ .all()
+ )
+
+ def delete(self, subtask: Subtask) -> None:
+ self.db.delete(subtask)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_assignment_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_assignment_repository.py
new file mode 100644
index 00000000..3a3e5a97
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_assignment_repository.py
@@ -0,0 +1,26 @@
+from sqlalchemy.orm import Session
+
+from app.models.task_assignment import TaskAssignment
+
+
+class TaskAssignmentRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def bulk_create(self, assignments: list[TaskAssignment]) -> list[TaskAssignment]:
+ self.db.add_all(assignments)
+ self.db.flush()
+ return assignments
+
+ def list_by_task(self, task_id: int) -> list[TaskAssignment]:
+ return self.db.query(TaskAssignment).filter(TaskAssignment.task_id == task_id).all()
+
+ def get_by_task_and_trainee(self, task_id: int, trainee_id: int) -> TaskAssignment | None:
+ return (
+ self.db.query(TaskAssignment)
+ .filter(TaskAssignment.task_id == task_id, TaskAssignment.trainee_id == trainee_id)
+ .first()
+ )
+
+ def list_by_trainee(self, trainee_id: int) -> list[TaskAssignment]:
+ return self.db.query(TaskAssignment).filter(TaskAssignment.trainee_id == trainee_id).all()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_repository.py
new file mode 100644
index 00000000..3016f3d0
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_repository.py
@@ -0,0 +1,27 @@
+from sqlalchemy.orm import Session
+
+from app.models.task import Task
+
+
+class TaskRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create(self, task: Task) -> Task:
+ self.db.add(task)
+ self.db.flush()
+ return task
+
+ def get_by_id(self, task_id: int) -> Task | None:
+ return self.db.get(Task, task_id)
+
+ def list_by_manager(self, manager_id: int) -> list[Task]:
+ return (
+ self.db.query(Task)
+ .filter(Task.created_by == manager_id)
+ .order_by(Task.created_at.desc())
+ .all()
+ )
+
+ def delete(self, task: Task) -> None:
+ self.db.delete(task)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/user_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/user_repository.py
new file mode 100644
index 00000000..70c77e70
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/user_repository.py
@@ -0,0 +1,17 @@
+from sqlalchemy.orm import Session
+
+from app.models.user import User
+
+
+class UserRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_by_id(self, user_id: int) -> User | None:
+ return self.db.get(User, user_id)
+
+ def get_by_email(self, email: str) -> User | None:
+ return self.db.query(User).filter(User.email == email).first()
+
+ def list_by_cohort(self, cohort_id: int) -> list[User]:
+ return self.db.query(User).filter(User.cohort_id == cohort_id).all()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/auth.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/auth.py
new file mode 100644
index 00000000..50926555
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/auth.py
@@ -0,0 +1,30 @@
+from pydantic import EmailStr, Field
+
+from app.schemas.base import CamelModel
+
+
+class LoginRequest(CamelModel):
+ email: EmailStr
+ password: str
+
+
+class ProfileUpdate(CamelModel):
+ full_name: str | None = None
+ email: EmailStr | None = None
+ current_password: str | None = None
+ new_password: str | None = Field(default=None, min_length=8)
+
+
+class TokenResponse(CamelModel):
+ access_token: str
+ token_type: str = "bearer"
+ role: str
+ user_id: int
+
+
+class UserOut(CamelModel):
+ id: int
+ email: EmailStr
+ full_name: str
+ role: str
+ cohort_id: int | None = None
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/base.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/base.py
new file mode 100644
index 00000000..0eda4fab
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/base.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+
+class CamelModel(BaseModel):
+ """Base for every schema: Python stays snake_case, JSON on the wire is camelCase."""
+
+ model_config = ConfigDict(
+ alias_generator=to_camel,
+ populate_by_name=True,
+ from_attributes=True,
+ )
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/cohort.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/cohort.py
new file mode 100644
index 00000000..b04d2e08
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/cohort.py
@@ -0,0 +1,123 @@
+from datetime import datetime
+
+from app.schemas.base import CamelModel
+
+# ============================================================
+# CREATE COHORT
+# ============================================================
+
+class CohortCreate(CamelModel):
+ name: str
+ description: str | None = None
+
+
+# ============================================================
+# COHORT TRAINEE
+# ============================================================
+
+class CohortTraineeOut(CamelModel):
+ id: int
+ full_name: str
+ email: str
+ current_percentage: int = 0
+
+
+# ============================================================
+# COHORT TASK
+# ============================================================
+
+class CohortTaskOut(CamelModel):
+ id: int
+ title: str
+ description: str | None
+ due_date: datetime | None
+ urgency: str
+
+ # Average progress for this task across assigned trainees.
+ average_percentage: float = 0.0
+
+
+# ============================================================
+# COHORT LIST
+# ============================================================
+
+class CohortOut(CamelModel):
+ id: int
+ name: str
+ description: str | None
+ trainee_count: int
+
+
+# ============================================================
+# PROGRESS HISTORY
+# ============================================================
+
+class ProgressHistoryEntry(CamelModel):
+ percentage: int
+ comment: str | None = None
+ created_at: datetime
+
+
+# ============================================================
+# SUBTASK PROGRESS
+# ============================================================
+
+class CohortSubtaskOut(CamelModel):
+ id: int
+ title: str
+ order_index: int
+
+ # True when the trainee has completed this subtask.
+ is_completed: bool = False
+
+ # Derived percentage:
+ # incomplete = 0
+ # complete = 100
+ percentage: int = 0
+
+
+# ============================================================
+# TRAINEE TASK PROGRESS
+# ============================================================
+
+class CohortTraineeTaskOut(CamelModel):
+ id: int
+ title: str
+ description: str | None = None
+
+ # Overall progress for this task for this trainee.
+ current_percentage: int = 0
+
+ subtasks: list[CohortSubtaskOut] = []
+
+ history: list[ProgressHistoryEntry] = []
+
+
+# ============================================================
+# COHORT TRAINEE DETAIL
+# ============================================================
+
+class CohortTraineeDetailOut(CamelModel):
+ id: int
+ full_name: str
+ email: str
+
+ # Overall progress for the trainee's cohort tasks.
+ current_percentage: int = 0
+
+ # Tasks assigned to this trainee through the cohort.
+ tasks: list[CohortTraineeTaskOut] = []
+
+
+# ============================================================
+# COHORT DETAIL
+# ============================================================
+
+class CohortDetailOut(CamelModel):
+ id: int
+ name: str
+ description: str | None
+
+ trainees: list[CohortTraineeDetailOut] = []
+
+ tasks: list[CohortTaskOut] = []
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/progress.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/progress.py
new file mode 100644
index 00000000..6b83cd70
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/progress.py
@@ -0,0 +1,47 @@
+from datetime import datetime
+
+from pydantic import Field
+
+from app.models.enums import UrgencyLevel
+from app.schemas.base import CamelModel
+from app.schemas.task import SubtaskOut
+
+
+class SubtaskToggle(CamelModel):
+ is_completed: bool
+
+
+class SubtaskToggleOut(CamelModel):
+ id: int
+ is_completed: bool
+
+
+class ProgressUpdateCreate(CamelModel):
+ percentage: int = Field(ge=0, le=100)
+ comment: str | None = None
+
+
+class ProgressUpdateOut(CamelModel):
+ task_id: int
+ percentage: int
+ comment: str | None
+ created_at: datetime
+
+
+class StudentTaskListItem(CamelModel):
+ task_id: int
+ title: str
+ due_date: datetime | None
+ urgency: UrgencyLevel
+ current_percentage: int
+ subtask_summary: str
+
+
+class StudentTaskDetail(CamelModel):
+ id: int
+ title: str
+ description: str | None
+ due_date: datetime | None
+ urgency: UrgencyLevel
+ subtasks: list[SubtaskOut]
+ current_percentage: int
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/task.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/task.py
new file mode 100644
index 00000000..62ee1d8d
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/task.py
@@ -0,0 +1,229 @@
+from datetime import datetime
+
+from pydantic import Field, model_validator
+
+from app.models.enums import UrgencyLevel
+from app.schemas.base import CamelModel
+
+
+# ============================================================
+# SUBTASKS
+# ============================================================
+
+
+class SubtaskCreate(CamelModel):
+ title: str
+ order_index: int = 0
+
+
+class SubtaskUpdate(CamelModel):
+ title: str | None = None
+ order_index: int | None = None
+
+
+class SubtaskOut(CamelModel):
+ id: int
+ title: str
+ order_index: int
+ is_completed: bool = False
+
+
+# ============================================================
+# TASK CREATE / UPDATE
+# ============================================================
+
+
+class TaskCreate(CamelModel):
+ title: str
+ description: str | None = None
+ due_date: datetime | None = None
+ urgency: UrgencyLevel = UrgencyLevel.medium
+
+ # A task can be assigned either to:
+ # 1. an entire cohort
+ # 2. individual trainees
+ #
+ # Exactly one target must be provided.
+
+ cohort_id: int | None = None
+ trainee_ids: list[int] | None = None
+
+ subtasks: list[SubtaskCreate] = Field(default_factory=list)
+
+ @model_validator(mode="after")
+ def check_exactly_one_target(self) -> "TaskCreate":
+ has_cohort = self.cohort_id is not None
+ has_trainees = bool(self.trainee_ids)
+
+ if has_cohort == has_trainees:
+ raise ValueError(
+ "provide exactly one of cohortId or traineeIds"
+ )
+
+ return self
+
+
+class TaskUpdate(CamelModel):
+ title: str | None = None
+ description: str | None = None
+ due_date: datetime | None = None
+ urgency: UrgencyLevel | None = None
+
+
+# ============================================================
+# COHORT REFERENCE
+# ============================================================
+
+
+class CohortRef(CamelModel):
+ id: int
+ name: str
+
+
+# ============================================================
+# TRAINEE REFERENCE
+# ============================================================
+
+
+class TraineeRef(CamelModel):
+ id: int
+ full_name: str
+
+
+# ============================================================
+# TASK OUTPUT
+# ============================================================
+
+
+class TaskOut(CamelModel):
+ id: int
+ title: str
+ description: str | None
+ due_date: datetime | None
+ urgency: UrgencyLevel
+
+ # Cohort information.
+ #
+ # Example JSON:
+ #
+ # {
+ # "cohortId": 1,
+ # "cohort": {
+ # "id": 1,
+ # "name": "Cohort A"
+ # }
+ # }
+
+ cohort_id: int | None = None
+ cohort: CohortRef | None = None
+
+ assigned_trainee_count: int
+
+
+class TaskDetailOut(CamelModel):
+ id: int
+ title: str
+ description: str | None
+ due_date: datetime | None
+ urgency: UrgencyLevel
+
+ cohort_id: int | None = None
+ cohort: CohortRef | None = None
+
+ subtasks: list[SubtaskOut]
+
+
+# ============================================================
+# TASK PROGRESS
+# ============================================================
+
+
+class TraineeProgressSummary(CamelModel):
+ """
+ Summary shown when viewing task progress.
+
+ current_percentage should be calculated by the backend
+ from the trainee's completed subtasks.
+
+ For the completed-subtask calculation:
+
+ completed_subtasks / total_subtasks * 100
+
+ Example:
+
+ 3 completed / 4 total = 75%
+ """
+
+ trainee_id: int
+ full_name: str
+ current_percentage: int
+ last_updated_at: datetime | None = None
+
+
+class TaskProgressOut(CamelModel):
+ """
+ Overall progress for a task.
+
+ average_percentage is the average of the individual
+ trainee percentages for this task.
+ """
+
+ task_id: int
+ average_percentage: float
+ trainees: list[TraineeProgressSummary]
+
+
+# ============================================================
+# PROGRESS HISTORY
+# ============================================================
+
+
+class ProgressHistoryEntry(CamelModel):
+ """
+ A historical progress update made for a trainee.
+
+ The frontend can display the percentage, comment,
+ and timestamp when the trainee's progress is expanded.
+ """
+
+ percentage: int
+ comment: str | None = None
+ created_at: datetime
+
+
+# ============================================================
+# TRAINEE PROGRESS DETAIL
+# ============================================================
+
+
+class TraineeProgressDetailOut(CamelModel):
+ """
+ Detailed progress for one trainee on one task.
+
+ Used by:
+
+ GET /manager/tasks/{task_id}/progress/{trainee_id}
+
+ This allows the frontend to display:
+
+ Trainee
+ Current Progress
+ Subtask 1
+ Subtask 2
+ Subtask 3
+ Progress History
+ 50% - comment
+ 75% - comment
+ 100% - comment
+ """
+
+ trainee: TraineeRef
+
+ # Calculated from completed subtasks.
+ current_percentage: int
+
+ # All subtasks for the task, including their completion state.
+ subtasks: list[SubtaskOut]
+
+ # Historical progress updates/comments.
+ history: list[ProgressHistoryEntry]
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/trainee.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/trainee.py
new file mode 100644
index 00000000..e2c585a5
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/trainee.py
@@ -0,0 +1,72 @@
+from datetime import datetime
+
+from pydantic import Field
+
+from app.models.enums import UrgencyLevel
+from app.schemas.base import CamelModel
+
+
+# ============================================================
+# COHORT
+# ============================================================
+
+class TraineeCohortOut(CamelModel):
+ id: int
+ name: str
+
+
+# ============================================================
+# TASK
+# ============================================================
+
+class TraineeTaskOut(CamelModel):
+ id: int
+ title: str
+ description: str | None
+ due_date: datetime | None
+ urgency: UrgencyLevel
+ current_percentage: int
+
+
+# ============================================================
+# TRAINEE DETAIL
+# ============================================================
+
+class TraineeOut(CamelModel):
+ id: int
+ full_name: str
+ email: str
+ cohort: TraineeCohortOut | None
+ tasks: list[TraineeTaskOut]
+
+
+# ============================================================
+# TRAINEE LIST ITEM
+# ============================================================
+
+class TraineeListItem(CamelModel):
+ id: int
+ full_name: str
+ email: str
+ cohort: TraineeCohortOut | None
+
+
+# ============================================================
+# CREATE TRAINEE
+# ============================================================
+
+class TraineeCreate(CamelModel):
+ full_name: str = Field(min_length=1)
+ email: str
+ password: str = Field(min_length=8)
+ cohort_id: int | None = None
+
+
+# ============================================================
+# UPDATE TRAINEE
+# ============================================================
+
+class TraineeUpdate(CamelModel):
+ full_name: str | None = Field(default=None, min_length=1)
+ email: str | None = None
+ cohort_id: int | None = None
\ No newline at end of file
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/auth_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/auth_service.py
new file mode 100644
index 00000000..193db97b
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/auth_service.py
@@ -0,0 +1,42 @@
+from sqlalchemy.orm import Session
+
+from app.core.security import create_access_token, hash_password, verify_password
+from app.models.user import User
+from app.repositories.user_repository import UserRepository
+from app.schemas.auth import ProfileUpdate
+
+
+class AuthService:
+ def __init__(self, db: Session):
+ self.db = db
+ self.users = UserRepository(db)
+
+ def authenticate(self, email: str, password: str) -> User | None:
+ user = self.users.get_by_email(email)
+ if user is None or not verify_password(password, user.hashed_password):
+ return None
+ return user
+
+ def issue_token(self, user: User) -> str:
+ return create_access_token(subject=user.id, role=user.role.value)
+
+ def update_profile(self, user: User, payload: ProfileUpdate) -> User:
+ if payload.email and payload.email != user.email:
+ existing = self.users.get_by_email(payload.email)
+ if existing is not None and existing.id != user.id:
+ raise ValueError("Email is already in use")
+ user.email = payload.email
+
+ if payload.full_name:
+ user.full_name = payload.full_name
+
+ if payload.new_password:
+ if not payload.current_password or not verify_password(
+ payload.current_password, user.hashed_password
+ ):
+ raise ValueError("Current password is incorrect")
+ user.hashed_password = hash_password(payload.new_password)
+
+ self.db.commit()
+ self.db.refresh(user)
+ return user
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/cohort_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/cohort_service.py
new file mode 100644
index 00000000..95b8a830
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/cohort_service.py
@@ -0,0 +1,236 @@
+from sqlalchemy.orm import Session, joinedload
+
+from app.models.task import Task
+from app.models.cohort import Cohort
+from app.models.user import User
+
+from app.repositories.progress_repository import ProgressRepository
+from app.repositories.task_assignment_repository import TaskAssignmentRepository
+
+from app.schemas.cohort import CohortCreate
+
+
+class CohortService:
+ def __init__(self, db: Session):
+ self.db = db
+ self.progress = ProgressRepository(db)
+ self.assignments = TaskAssignmentRepository(db)
+
+ # ============================================================
+ # CREATE
+ # ============================================================
+
+ def create_cohort(
+ self,
+ manager_id: int,
+ payload: CohortCreate,
+ ) -> Cohort:
+
+ cohort = Cohort(
+ name=payload.name.strip(),
+ description=(
+ payload.description.strip()
+ if payload.description
+ else None
+ ),
+ manager_id=manager_id,
+ )
+
+ self.db.add(cohort)
+ self.db.commit()
+ self.db.refresh(cohort)
+
+ return cohort
+
+ # ============================================================
+ # LIST
+ # ============================================================
+
+ def list_manager_cohorts(
+ self,
+ manager_id: int,
+ ) -> list[Cohort]:
+
+ return (
+ self.db.query(Cohort)
+ .options(
+ joinedload(Cohort.trainees),
+ )
+ .filter(
+ Cohort.manager_id == manager_id,
+ )
+ .all()
+ )
+
+ # ============================================================
+ # GET
+ # ============================================================
+
+ def get_manager_cohort(
+ self,
+ manager_id: int,
+ cohort_id: int,
+ ) -> Cohort | None:
+
+ return (
+ self.db.query(Cohort)
+ .options(
+ joinedload(Cohort.trainees),
+ joinedload(Cohort.tasks),
+ )
+ .filter(
+ Cohort.id == cohort_id,
+ Cohort.manager_id == manager_id,
+ )
+ .first()
+ )
+
+ # ============================================================
+ # TRAINEE PROGRESS
+ # ============================================================
+
+ def get_trainee_progress(
+ self,
+ trainee_id: int,
+ task_id: int,
+ ) -> int:
+
+ assignment = (
+ self.assignments.get_by_task_and_trainee(
+ task_id,
+ trainee_id,
+ )
+ )
+
+ if assignment is None:
+ return 0
+
+ return int(
+ assignment.current_percentage or 0
+ )
+
+ # ============================================================
+ # COHORT TASK AVERAGE
+ # ============================================================
+
+ def get_task_average_percentage(
+ self,
+ task_id: int,
+ ) -> float:
+
+ assignments = self.assignments.list_by_task(
+ task_id,
+ )
+
+ if not assignments:
+ return 0.0
+
+ total = sum(
+ int(assignment.current_percentage or 0)
+ for assignment in assignments
+ )
+
+ return round(
+ total / len(assignments),
+ 1,
+ )
+
+ # ============================================================
+ # COHORT TRAINEE AVERAGE
+ # ============================================================
+
+ def get_cohort_trainee_percentage(
+ self,
+ trainee_id: int,
+ tasks,
+ ) -> int:
+
+ if not tasks:
+ return 0
+
+ percentages = []
+
+ for task in tasks:
+ assignment = (
+ self.assignments.get_by_task_and_trainee(
+ task.id,
+ trainee_id,
+ )
+ )
+
+ if assignment is not None:
+ percentages.append(
+ int(
+ assignment.current_percentage or 0
+ )
+ )
+
+ if not percentages:
+ return 0
+
+ return round(
+ sum(percentages) / len(percentages)
+ )
+
+ # ============================================================
+ # DELETE
+ # ============================================================
+
+ def delete_cohort(
+ self,
+ manager_id: int,
+ cohort_id: int,
+ ) -> bool:
+
+ cohort = (
+ self.db.query(Cohort)
+ .filter(
+ Cohort.id == cohort_id,
+ Cohort.manager_id == manager_id,
+ )
+ .first()
+ )
+
+ if cohort is None:
+ return False
+
+ # --------------------------------------------------------
+ # Remove trainees from the cohort.
+ #
+ # Trainees are NOT deleted.
+ # Their cohort_id is simply cleared.
+ # --------------------------------------------------------
+
+ self.db.query(User).filter(
+ User.cohort_id == cohort.id,
+ ).update(
+ {
+ User.cohort_id: None,
+ },
+ synchronize_session=False,
+ )
+
+ # --------------------------------------------------------
+ # Remove tasks from the cohort.
+ #
+ # Tasks are NOT deleted.
+ # Their cohort_id is simply cleared.
+ # --------------------------------------------------------
+
+ self.db.query(Task).filter(
+ Task.cohort_id == cohort.id,
+ ).update(
+ {
+ Task.cohort_id: None,
+ },
+ synchronize_session=False,
+ )
+
+ # --------------------------------------------------------
+ # Delete the cohort
+ # --------------------------------------------------------
+
+ self.db.delete(cohort)
+ self.db.commit()
+
+ return True
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/progress_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/progress_service.py
new file mode 100644
index 00000000..6d2a98a3
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/progress_service.py
@@ -0,0 +1,316 @@
+from datetime import datetime, timezone
+
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.models.task_assignment import TaskAssignment
+from app.repositories.progress_repository import ProgressRepository
+from app.repositories.subtask_repository import SubtaskRepository
+from app.repositories.task_assignment_repository import TaskAssignmentRepository
+
+
+_URGENCY_RANK = {
+ "urgent": 0,
+ "high": 1,
+ "medium": 2,
+ "low": 3,
+}
+
+
+class ProgressService:
+
+ def __init__(self, db: Session):
+ self.db = db
+ self.subtasks = SubtaskRepository(db)
+ self.assignments = TaskAssignmentRepository(db)
+ self.progress = ProgressRepository(db)
+
+ # ========================================================
+ # HELPERS
+ # ========================================================
+
+ def _get_own_assignment(
+ self,
+ trainee_id: int,
+ task_id: int,
+ ) -> TaskAssignment:
+
+ assignment = self.assignments.get_by_task_and_trainee(
+ task_id,
+ trainee_id,
+ )
+
+ if assignment is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "Task not assigned to this trainee",
+ )
+
+ return assignment
+
+ # ========================================================
+ # STUDENT TASK LIST
+ # ========================================================
+
+ def list_student_tasks(
+ self,
+ trainee_id: int,
+ sort: str | None = None,
+ ) -> list[dict]:
+
+ assignments = self.assignments.list_by_trainee(trainee_id)
+
+ items = []
+
+ for assignment in assignments:
+
+ task = assignment.task
+
+ subtasks = self.subtasks.list_by_task(task.id)
+
+ completions = self.progress.list_completions_by_task(
+ [subtask.id for subtask in subtasks],
+ trainee_id,
+ )
+
+ completed_count = sum(
+ 1
+ for subtask in subtasks
+ if completions.get(subtask.id, False)
+ )
+
+ items.append(
+ {
+ "task_id": task.id,
+ "title": task.title,
+ "due_date": task.due_date,
+ "urgency": task.urgency,
+ "current_percentage": assignment.current_percentage,
+ "subtask_summary": (
+ f"{completed_count}/{len(subtasks)} completed"
+ ),
+ }
+ )
+
+ if sort == "urgency":
+ items.sort(
+ key=lambda item: _URGENCY_RANK.get(
+ item["urgency"].value,
+ 4,
+ )
+ )
+
+ return items
+
+ # ========================================================
+ # STUDENT TASK DETAIL
+ # ========================================================
+
+ def get_student_task_detail(
+ self,
+ trainee_id: int,
+ task_id: int,
+ ) -> dict:
+
+ assignment = self._get_own_assignment(
+ trainee_id,
+ task_id,
+ )
+
+ task = assignment.task
+
+ subtasks = self.subtasks.list_by_task(task_id)
+
+ completions = self.progress.list_completions_by_task(
+ [subtask.id for subtask in subtasks],
+ trainee_id,
+ )
+
+ return {
+ "id": task.id,
+ "title": task.title,
+ "description": task.description,
+ "due_date": task.due_date,
+ "urgency": task.urgency,
+
+ "subtasks": [
+ {
+ "id": subtask.id,
+ "title": subtask.title,
+ "order_index": subtask.order_index,
+ "is_completed": completions.get(
+ subtask.id,
+ False,
+ ),
+ }
+ for subtask in subtasks
+ ],
+
+ "current_percentage": assignment.current_percentage,
+ }
+
+ # ========================================================
+ # TOGGLE SUBTASK
+ # ========================================================
+
+ def toggle_subtask(
+ self,
+ trainee_id: int,
+ subtask_id: int,
+ is_completed: bool,
+ ) -> dict:
+
+ # ----------------------------------------------------
+ # Find subtask
+ # ----------------------------------------------------
+
+ subtask = self.subtasks.get_by_id(subtask_id)
+
+ if subtask is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "Subtask not found",
+ )
+
+ # ----------------------------------------------------
+ # Make sure trainee owns the task
+ # ----------------------------------------------------
+
+ assignment = self._get_own_assignment(
+ trainee_id,
+ subtask.task_id,
+ )
+
+ # ----------------------------------------------------
+ # Update completion
+ # ----------------------------------------------------
+
+ completion = self.progress.set_completion(
+ subtask_id,
+ trainee_id,
+ is_completed,
+ )
+
+ # ----------------------------------------------------
+ # Get all subtasks for this task
+ # ----------------------------------------------------
+
+ subtasks = self.subtasks.list_by_task(
+ subtask.task_id
+ )
+
+ # ----------------------------------------------------
+ # Get completion status
+ # ----------------------------------------------------
+
+ completions = self.progress.list_completions_by_task(
+ [s.id for s in subtasks],
+ trainee_id,
+ )
+
+ # ----------------------------------------------------
+ # Calculate progress
+ # ----------------------------------------------------
+
+ completed_count = sum(
+ 1
+ for s in subtasks
+ if completions.get(s.id, False)
+ )
+
+ total_count = len(subtasks)
+
+ if total_count > 0:
+ percentage = round(
+ (completed_count / total_count) * 100
+ )
+ else:
+ percentage = 0
+
+ # ----------------------------------------------------
+ # Update task assignment
+ # ----------------------------------------------------
+
+ assignment.current_percentage = percentage
+
+ assignment.last_updated_at = datetime.now(
+ timezone.utc
+ )
+
+ self.db.commit()
+
+ # ----------------------------------------------------
+ # Return result
+ # ----------------------------------------------------
+
+ return {
+ "id": subtask_id,
+ "is_completed": completion.is_completed,
+ "percentage": percentage,
+ }
+
+ # ========================================================
+ # MANUAL PROGRESS UPDATE
+ # ========================================================
+
+ def submit_progress_update(
+ self,
+ trainee_id: int,
+ task_id: int,
+ percentage: int,
+ comment: str | None,
+ ) -> dict:
+
+ assignment = self._get_own_assignment(
+ trainee_id,
+ task_id,
+ )
+
+ entry = self.progress.add_history_entry(
+ assignment.id,
+ percentage,
+ comment,
+ )
+
+ assignment.current_percentage = percentage
+
+ assignment.last_updated_at = datetime.now(
+ timezone.utc
+ )
+
+ self.db.commit()
+
+ return {
+ "task_id": task_id,
+ "percentage": entry.percentage,
+ "comment": entry.comment,
+ "created_at": entry.created_at,
+ }
+
+ # ========================================================
+ # PROGRESS HISTORY
+ # ========================================================
+
+ def get_progress_history(
+ self,
+ trainee_id: int,
+ task_id: int,
+ ) -> list[dict]:
+
+ assignment = self._get_own_assignment(
+ trainee_id,
+ task_id,
+ )
+
+ history = self.progress.list_history(
+ assignment.id
+ )
+
+ return [
+ {
+ "percentage": entry.percentage,
+ "comment": entry.comment,
+ "created_at": entry.created_at,
+ }
+ for entry in history
+ ]
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_assignment_factory.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_assignment_factory.py
new file mode 100644
index 00000000..fa3b9c96
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_assignment_factory.py
@@ -0,0 +1,14 @@
+from app.models.task_assignment import TaskAssignment
+from app.models.user import User
+
+
+class TaskAssignmentFactory:
+ """Builds the per-trainee task_assignments fan-out for a newly created task."""
+
+ @staticmethod
+ def build_for_cohort(task_id: int, trainees: list[User]) -> list[TaskAssignment]:
+ return [TaskAssignment(task_id=task_id, trainee_id=trainee.id) for trainee in trainees]
+
+ @staticmethod
+ def build_for_trainee_ids(task_id: int, trainee_ids: list[int]) -> list[TaskAssignment]:
+ return [TaskAssignment(task_id=task_id, trainee_id=trainee_id) for trainee_id in trainee_ids]
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_service.py
new file mode 100644
index 00000000..ccb75b69
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_service.py
@@ -0,0 +1,121 @@
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.models.subtask import Subtask
+from app.models.task import Task
+from app.repositories.progress_repository import ProgressRepository
+from app.repositories.subtask_repository import SubtaskRepository
+from app.repositories.task_assignment_repository import TaskAssignmentRepository
+from app.repositories.task_repository import TaskRepository
+from app.repositories.user_repository import UserRepository
+from app.schemas.task import TaskCreate, TaskUpdate
+from app.services.task_assignment_factory import TaskAssignmentFactory
+
+
+class TaskService:
+ def __init__(self, db: Session):
+ self.db = db
+ self.tasks = TaskRepository(db)
+ self.subtasks = SubtaskRepository(db)
+ self.assignments = TaskAssignmentRepository(db)
+ self.users = UserRepository(db)
+ self.progress = ProgressRepository(db)
+
+ def create_task(self, manager_id: int, payload: TaskCreate) -> Task:
+ task = Task(
+ title=payload.title,
+ description=payload.description,
+ created_by=manager_id,
+ cohort_id=payload.cohort_id,
+ due_date=payload.due_date,
+ urgency=payload.urgency,
+ )
+ self.tasks.create(task)
+
+ for item in payload.subtasks:
+ self.subtasks.create(Subtask(task_id=task.id, title=item.title, order_index=item.order_index))
+
+ if payload.cohort_id:
+ trainees = self.users.list_by_cohort(payload.cohort_id)
+ assignments = TaskAssignmentFactory.build_for_cohort(task.id, trainees)
+ else:
+ assignments = TaskAssignmentFactory.build_for_trainee_ids(task.id, payload.trainee_ids or [])
+
+ self.assignments.bulk_create(assignments)
+ self.db.commit()
+ self.db.refresh(task)
+ return task
+
+ def get_owned_task(self, manager_id: int, task_id: int) -> Task:
+ task = self.tasks.get_by_id(task_id)
+ if task is None or task.created_by != manager_id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found")
+ return task
+
+ def list_manager_tasks(self, manager_id: int) -> list[Task]:
+ return self.tasks.list_by_manager(manager_id)
+
+ def update_task(self, manager_id: int, task_id: int, payload: TaskUpdate) -> Task:
+ task = self.get_owned_task(manager_id, task_id)
+ for field, value in payload.model_dump(exclude_unset=True).items():
+ setattr(task, field, value)
+ self.db.commit()
+ self.db.refresh(task)
+ return task
+
+ def delete_task(self, manager_id: int, task_id: int) -> None:
+ task = self.get_owned_task(manager_id, task_id)
+ self.tasks.delete(task)
+ self.db.commit()
+
+ def add_subtask(self, manager_id: int, task_id: int, title: str, order_index: int) -> Subtask:
+ self.get_owned_task(manager_id, task_id)
+ subtask = self.subtasks.create(Subtask(task_id=task_id, title=title, order_index=order_index))
+ self.db.commit()
+ self.db.refresh(subtask)
+ return subtask
+
+ def get_task_progress_summary(self, manager_id: int, task_id: int) -> dict:
+ self.get_owned_task(manager_id, task_id)
+ rows = self.assignments.list_by_task(task_id)
+ average = sum(row.current_percentage for row in rows) / len(rows) if rows else 0.0
+ return {
+ "task_id": task_id,
+ "average_percentage": round(average, 1),
+ "trainees": [
+ {
+ "trainee_id": row.trainee_id,
+ "full_name": row.trainee.full_name,
+ "current_percentage": row.current_percentage,
+ "last_updated_at": row.last_updated_at,
+ }
+ for row in rows
+ ],
+ }
+
+ def get_trainee_progress_detail(self, manager_id: int, task_id: int, trainee_id: int) -> dict:
+ self.get_owned_task(manager_id, task_id)
+ assignment = self.assignments.get_by_task_and_trainee(task_id, trainee_id)
+ if assignment is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Trainee is not assigned to this task")
+
+ subtasks = self.subtasks.list_by_task(task_id)
+ completions = self.progress.list_completions_by_task([s.id for s in subtasks], trainee_id)
+ history = self.progress.list_history(assignment.id)
+
+ return {
+ "trainee": {"id": assignment.trainee.id, "full_name": assignment.trainee.full_name},
+ "current_percentage": assignment.current_percentage,
+ "subtasks": [
+ {
+ "id": s.id,
+ "title": s.title,
+ "order_index": s.order_index,
+ "is_completed": completions.get(s.id, False),
+ }
+ for s in subtasks
+ ],
+ "history": [
+ {"percentage": h.percentage, "comment": h.comment, "created_at": h.created_at} for h in history
+ ],
+ }
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/trainee_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/trainee_service.py
new file mode 100644
index 00000000..87e94be5
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/trainee_service.py
@@ -0,0 +1,450 @@
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session, joinedload
+
+from app.core.security import hash_password
+from app.models.cohort import Cohort
+from app.models.enums import UserRole
+from app.models.task import Task
+from app.models.user import User
+from app.models.task_assignment import TaskAssignment
+from app.models.subtask_completion import SubtaskCompletion
+from app.schemas.trainee import TraineeCreate, TraineeUpdate
+
+
+class TraineeService:
+
+ def __init__(self, db: Session):
+ self.db = db
+
+ # ========================================================
+ # LIST
+ # ========================================================
+
+ def list_manager_trainees(
+ self,
+ manager_id: int,
+ ) -> list[User]:
+
+ return (
+ self.db.query(User)
+ .filter(
+ User.manager_id == manager_id,
+ User.role == UserRole.trainee,
+ )
+ .options(
+ joinedload(User.cohort),
+ )
+ .order_by(User.full_name)
+ .all()
+ )
+
+ # ========================================================
+ # GET
+ # ========================================================
+
+ def get_manager_trainee(
+ self,
+ manager_id: int,
+ trainee_id: int,
+ ) -> User | None:
+
+ return (
+ self.db.query(User)
+ .filter(
+ User.id == trainee_id,
+ User.manager_id == manager_id,
+ User.role == UserRole.trainee,
+ )
+ .options(
+ joinedload(User.cohort),
+ joinedload(User.task_assignments).joinedload(
+ TaskAssignment.task
+ ),
+ )
+ .first()
+ )
+
+ # ========================================================
+ # CREATE
+ # ========================================================
+
+ def create_trainee(
+ self,
+ manager_id: int,
+ payload: TraineeCreate,
+ ) -> User:
+
+ # ----------------------------------------------------
+ # Normalize email
+ # ----------------------------------------------------
+
+ email = payload.email.strip().lower()
+
+ # ----------------------------------------------------
+ # Check for existing email
+ # ----------------------------------------------------
+
+ existing_user = (
+ self.db.query(User)
+ .filter(
+ User.email == email,
+ )
+ .first()
+ )
+
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="A user with this email already exists.",
+ )
+
+ # ----------------------------------------------------
+ # Validate cohort
+ #
+ # A trainee may be created without a cohort.
+ #
+ # If a cohort is supplied, it must belong to the
+ # current manager.
+ # ----------------------------------------------------
+
+ cohort_id = payload.cohort_id
+
+ if cohort_id is not None:
+
+ cohort = (
+ self.db.query(Cohort)
+ .filter(
+ Cohort.id == cohort_id,
+ Cohort.manager_id == manager_id,
+ )
+ .first()
+ )
+
+ if cohort is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Cohort not found.",
+ )
+
+ # ----------------------------------------------------
+ # Create trainee
+ # ----------------------------------------------------
+
+ trainee = User(
+ full_name=payload.full_name.strip(),
+ email=email,
+ hashed_password=hash_password(payload.password),
+ role=UserRole.trainee,
+
+ # Manager owns the trainee directly.
+ manager_id=manager_id,
+
+ # Can be NULL.
+ cohort_id=cohort_id,
+ )
+
+ self.db.add(trainee)
+
+ # We need trainee.id before creating assignments.
+ self.db.flush()
+
+ # ----------------------------------------------------
+ # ASSIGN COHORT TASKS
+ #
+ # If a cohort was selected, create one
+ # TaskAssignment for every task belonging to it.
+ # ----------------------------------------------------
+
+ if cohort_id is not None:
+
+ cohort_tasks = (
+ self.db.query(Task)
+ .filter(
+ Task.cohort_id == cohort_id,
+ )
+ .all()
+ )
+
+ for task in cohort_tasks:
+
+ assignment = TaskAssignment(
+ task_id=task.id,
+ trainee_id=trainee.id,
+ current_percentage=0,
+ )
+
+ self.db.add(assignment)
+
+ # ----------------------------------------------------
+ # Commit everything together
+ # ----------------------------------------------------
+
+ self.db.commit()
+
+ self.db.refresh(trainee)
+
+ return trainee
+
+ # ========================================================
+ # UPDATE
+ # ========================================================
+
+ def update_trainee(
+ self,
+ manager_id: int,
+ trainee_id: int,
+ payload: TraineeUpdate,
+ ) -> User | None:
+
+ # ----------------------------------------------------
+ # Find trainee belonging to this manager
+ #
+ # manager_id is the source of truth for ownership.
+ # ----------------------------------------------------
+
+ trainee = (
+ self.db.query(User)
+ .filter(
+ User.id == trainee_id,
+ User.manager_id == manager_id,
+ User.role == UserRole.trainee,
+ )
+ .first()
+ )
+
+ if trainee is None:
+ return None
+
+ # ----------------------------------------------------
+ # Get supplied fields
+ # ----------------------------------------------------
+
+ update_data = payload.model_dump(
+ exclude_unset=True,
+ )
+
+ # ----------------------------------------------------
+ # Normalize email
+ # ----------------------------------------------------
+
+ if "email" in update_data:
+
+ if update_data["email"] is not None:
+
+ email = update_data["email"].strip().lower()
+
+ existing_user = (
+ self.db.query(User)
+ .filter(
+ User.email == email,
+ User.id != trainee_id,
+ )
+ .first()
+ )
+
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="A user with this email already exists.",
+ )
+
+ update_data["email"] = email
+
+ # ----------------------------------------------------
+ # Normalize full name
+ # ----------------------------------------------------
+
+ if "full_name" in update_data:
+
+ if update_data["full_name"] is not None:
+
+ update_data["full_name"] = (
+ update_data["full_name"].strip()
+ )
+
+ # ----------------------------------------------------
+ # COHORT CHANGE
+ #
+ # None is allowed.
+ #
+ # Examples:
+ #
+ # None -> Cohort A
+ # Cohort A -> Cohort B
+ # Cohort A -> None
+ #
+ # When assigning/changing to a cohort, all tasks from
+ # the new cohort are assigned to the trainee.
+ #
+ # When removing/changing cohort, existing task
+ # assignments are removed.
+ # ----------------------------------------------------
+
+ cohort_changed = "cohort_id" in update_data
+
+ old_cohort_id = trainee.cohort_id
+ new_cohort_id = update_data.get(
+ "cohort_id",
+ old_cohort_id,
+ )
+
+ if cohort_changed:
+
+ # ------------------------------------------------
+ # Validate new cohort
+ # ------------------------------------------------
+
+ if new_cohort_id is not None:
+
+ cohort = (
+ self.db.query(Cohort)
+ .filter(
+ Cohort.id == new_cohort_id,
+ Cohort.manager_id == manager_id,
+ )
+ .first()
+ )
+
+ if cohort is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Cohort not found.",
+ )
+
+ # ------------------------------------------------
+ # Only do task reassignment if the cohort actually
+ # changed.
+ # ------------------------------------------------
+
+ if old_cohort_id != new_cohort_id:
+
+ # --------------------------------------------
+ # Remove existing task assignments.
+ #
+ # This means the trainee's task list always
+ # matches their current cohort.
+ # --------------------------------------------
+
+ self.db.query(TaskAssignment).filter(
+ TaskAssignment.trainee_id == trainee.id,
+ ).delete(
+ synchronize_session=False,
+ )
+
+ # --------------------------------------------
+ # Assign all tasks from the new cohort.
+ #
+ # If new_cohort_id is None, no tasks are added.
+ # --------------------------------------------
+
+ if new_cohort_id is not None:
+
+ cohort_tasks = (
+ self.db.query(Task)
+ .filter(
+ Task.cohort_id == new_cohort_id,
+ )
+ .all()
+ )
+
+ for task in cohort_tasks:
+
+ assignment = TaskAssignment(
+ task_id=task.id,
+ trainee_id=trainee.id,
+ current_percentage=0,
+ )
+
+ self.db.add(assignment)
+
+ # ----------------------------------------------------
+ # Apply normal trainee updates
+ # ----------------------------------------------------
+
+ for field, value in update_data.items():
+
+ setattr(
+ trainee,
+ field,
+ value,
+ )
+
+ # ----------------------------------------------------
+ # Commit
+ # ----------------------------------------------------
+
+ self.db.commit()
+
+ self.db.refresh(trainee)
+
+ return trainee
+
+ # ========================================================
+ # DELETE
+ # ========================================================
+
+ def delete_trainee(
+ self,
+ manager_id: int,
+ trainee_id: int,
+ ) -> bool:
+
+ # ----------------------------------------------------
+ # Find trainee belonging to this manager
+ #
+ # Do not use cohort_id because it may be NULL.
+ # ----------------------------------------------------
+
+ trainee = (
+ self.db.query(User)
+ .filter(
+ User.id == trainee_id,
+ User.manager_id == manager_id,
+ User.role == UserRole.trainee,
+ )
+ .first()
+ )
+
+ if trainee is None:
+ return False
+
+ # ----------------------------------------------------
+ # Delete subtask completion records
+ #
+ # These records reference users.id through
+ # subtask_completions.trainee_id.
+ #
+ # They must be deleted before the trainee.
+ # ----------------------------------------------------
+
+ self.db.query(SubtaskCompletion).filter(
+ SubtaskCompletion.trainee_id == trainee.id,
+ ).delete(
+ synchronize_session=False,
+ )
+
+ # ----------------------------------------------------
+ # Delete task assignments
+ #
+ # These records also reference the trainee.
+ # ----------------------------------------------------
+
+ self.db.query(TaskAssignment).filter(
+ TaskAssignment.trainee_id == trainee.id,
+ ).delete(
+ synchronize_session=False,
+ )
+
+ # ----------------------------------------------------
+ # Delete trainee
+ # ----------------------------------------------------
+
+ self.db.delete(trainee)
+
+ # ----------------------------------------------------
+ # Commit everything together
+ # ----------------------------------------------------
+
+ self.db.commit()
+
+ return True
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/lambda_handler.py b/workshops/fullstack-aws/projects/submission/Group3/backend/lambda_handler.py
new file mode 100644
index 00000000..a6a2d6fe
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/lambda_handler.py
@@ -0,0 +1,5 @@
+from mangum import Mangum
+
+from app.main import app
+
+handler = Mangum(app)
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/requirements.txt b/workshops/fullstack-aws/projects/submission/Group3/backend/requirements.txt
new file mode 100644
index 00000000..e6057915
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/requirements.txt
@@ -0,0 +1,11 @@
+fastapi>=0.115
+uvicorn[standard]>=0.30
+sqlalchemy>=2.0
+psycopg2-binary>=2.9
+pydantic[email]>=2.7
+pydantic-settings>=2.3
+python-jose[cryptography]>=3.3
+passlib[bcrypt]>=1.7
+bcrypt<4.0
+python-multipart>=0.0.9
+alembic>=1.13
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/seed_dev_data.py b/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/seed_dev_data.py
new file mode 100644
index 00000000..3588ee7e
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/seed_dev_data.py
@@ -0,0 +1,413 @@
+"""Seeds a clean development database.
+
+This script deletes all existing application data and then creates:
+
+- 1 manager
+- 1 cohort
+- 2 trainees
+- 2 tasks
+- 6 subtasks
+- Task assignments for both trainees
+
+Run from the backend/ directory:
+
+ python -m scripts.seed_dev_data
+
+WARNING:
+ This permanently deletes all data in the development database.
+ Do NOT use this script against a production database.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+from app.core.database import SessionLocal
+from app.core.security import hash_password
+
+from app.models.cohort import Cohort
+from app.models.enums import UserRole, UrgencyLevel
+from app.models.user import User
+from app.models.task import Task
+from app.models.subtask import Subtask
+from app.models.task_assignment import TaskAssignment
+from app.models.subtask_completion import SubtaskCompletion
+from app.models.progress_update import ProgressUpdate
+
+
+def clear_database(db) -> None:
+ """Delete all application data in dependency-safe order."""
+
+ print("Clearing existing development data...")
+
+ # ========================================================
+ # BREAK USER <-> COHORT FOREIGN KEY CYCLE
+ #
+ # users.cohort_id -> cohorts.id
+ # cohorts.manager_id -> users.id
+ #
+ # We first remove those references so PostgreSQL allows
+ # us to delete the records.
+ # ========================================================
+
+ db.execute(
+ User.__table__.update().values(
+ manager_id=None,
+ cohort_id=None,
+ )
+ )
+
+ db.execute(
+ Cohort.__table__.update().values(
+ manager_id=None,
+ )
+ )
+
+ # ========================================================
+ # CHILD TABLES
+ # ========================================================
+
+ db.query(ProgressUpdate).delete(
+ synchronize_session=False
+ )
+
+ db.query(SubtaskCompletion).delete(
+ synchronize_session=False
+ )
+
+ db.query(TaskAssignment).delete(
+ synchronize_session=False
+ )
+
+ db.query(Subtask).delete(
+ synchronize_session=False
+ )
+
+ db.query(Task).delete(
+ synchronize_session=False
+ )
+
+ # ========================================================
+ # COHORTS
+ # ========================================================
+
+ db.query(Cohort).delete(
+ synchronize_session=False
+ )
+
+ # ========================================================
+ # USERS
+ # ========================================================
+
+ db.query(User).delete(
+ synchronize_session=False
+ )
+
+ db.commit()
+
+ print("Database cleared.")
+
+
+def seed_database(db) -> None:
+ """Create development seed data."""
+
+ # ========================================================
+ # MANAGER
+ # ========================================================
+
+ manager = User(
+ email="manager@noticeboardtracker.dev",
+ hashed_password=hash_password("Manager123!"),
+ full_name="Morgan Manager",
+ role=UserRole.manager,
+ )
+
+ db.add(manager)
+ db.flush()
+
+ # ========================================================
+ # COHORT
+ # ========================================================
+
+ cohort = Cohort(
+ name="Cohort Alpha",
+ description="Test cohort",
+ manager_id=manager.id,
+ )
+
+ db.add(cohort)
+ db.flush()
+
+ # ========================================================
+ # TRAINEES
+ # ========================================================
+
+ trainees = [
+ User(
+ email=f"trainee{i}@noticeboardtracker.dev",
+ hashed_password=hash_password("Trainee123!"),
+ full_name=f"Trainee {i}",
+ role=UserRole.trainee,
+ manager_id=manager.id,
+ cohort_id=cohort.id,
+ )
+ for i in range(1, 3)
+ ]
+
+ db.add_all(trainees)
+ db.flush()
+
+ # ========================================================
+ # TASK 1
+ # ========================================================
+
+ task1 = Task(
+ title="Complete Python Onboarding",
+ description=(
+ "Complete the initial Python training and demonstrate "
+ "understanding of the core concepts."
+ ),
+ created_by=manager.id,
+ cohort_id=cohort.id,
+ due_date=datetime.now(timezone.utc) + timedelta(days=7),
+ urgency=UrgencyLevel.medium,
+ )
+
+ db.add(task1)
+ db.flush()
+
+ # ========================================================
+ # TASK 1 SUBTASKS
+ # ========================================================
+
+ task1_subtasks = [
+ Subtask(
+ task_id=task1.id,
+ title="Review Python syntax and data types",
+ order_index=1,
+ ),
+ Subtask(
+ task_id=task1.id,
+ title="Complete Python functions exercise",
+ order_index=2,
+ ),
+ Subtask(
+ task_id=task1.id,
+ title="Complete Python classes exercise",
+ order_index=3,
+ ),
+ ]
+
+ db.add_all(task1_subtasks)
+ db.flush()
+
+ # ========================================================
+ # TASK 2
+ # ========================================================
+
+ task2 = Task(
+ title="Build Noticeboard API Feature",
+ description=(
+ "Implement a small API feature using FastAPI and "
+ "SQLAlchemy."
+ ),
+ created_by=manager.id,
+ cohort_id=cohort.id,
+ due_date=datetime.now(timezone.utc) + timedelta(days=14),
+ urgency=UrgencyLevel.high,
+ )
+
+ db.add(task2)
+ db.flush()
+
+ # ========================================================
+ # TASK 2 SUBTASKS
+ # ========================================================
+
+ task2_subtasks = [
+ Subtask(
+ task_id=task2.id,
+ title="Create the database model",
+ order_index=1,
+ ),
+ Subtask(
+ task_id=task2.id,
+ title="Create the repository and service",
+ order_index=2,
+ ),
+ Subtask(
+ task_id=task2.id,
+ title="Create and test the API endpoint",
+ order_index=3,
+ ),
+ ]
+
+ db.add_all(task2_subtasks)
+ db.flush()
+
+ # ========================================================
+ # TASK ASSIGNMENTS
+ #
+ # Both tasks are assigned to both trainees.
+ # ========================================================
+
+ assignments = []
+
+ for trainee in trainees:
+ assignments.append(
+ TaskAssignment(
+ task_id=task1.id,
+ trainee_id=trainee.id,
+ current_percentage=0,
+ )
+ )
+
+ assignments.append(
+ TaskAssignment(
+ task_id=task2.id,
+ trainee_id=trainee.id,
+ current_percentage=0,
+ )
+ )
+
+ db.add_all(assignments)
+ db.flush()
+
+ # ========================================================
+ # OPTIONAL SAMPLE PROGRESS
+ #
+ # Give Trainee 1 some progress so the manager dashboard
+ # has meaningful data immediately after seeding.
+ # ========================================================
+
+ trainee1 = trainees[0]
+
+ task1_assignment = next(
+ assignment
+ for assignment in assignments
+ if assignment.task_id == task1.id
+ and assignment.trainee_id == trainee1.id
+ )
+
+ task1_assignment.current_percentage = 33
+ task1_assignment.last_updated_at = datetime.now(timezone.utc)
+
+ # Mark the first subtask complete for Trainee 1.
+
+ db.add(
+ SubtaskCompletion(
+ subtask_id=task1_subtasks[0].id,
+ trainee_id=trainee1.id,
+ is_completed=True,
+ completed_at=datetime.now(timezone.utc),
+ )
+ )
+
+ # Add a progress history record.
+
+ db.add(
+ ProgressUpdate(
+ task_assignment_id=task1_assignment.id,
+ percentage=33,
+ comment="Completed the first onboarding section.",
+ )
+ )
+
+ # ========================================================
+ # COMMIT
+ # ========================================================
+
+ db.commit()
+
+ # ========================================================
+ # OUTPUT
+ # ========================================================
+
+ print()
+ print("Seed complete!")
+ print()
+
+ print("Manager:")
+ print(
+ " email = manager@noticeboardtracker.dev"
+ )
+ print(
+ " password = Manager123!"
+ )
+ print(
+ f" id = {manager.id}"
+ )
+
+ print()
+
+ print("Cohort:")
+ print(
+ f" name = {cohort.name}"
+ )
+ print(
+ f" id = {cohort.id}"
+ )
+
+ print()
+
+ print("Trainees:")
+
+ for trainee in trainees:
+ print(
+ f" {trainee.email} / Trainee123! "
+ f"(id={trainee.id}, "
+ f"manager_id={manager.id}, "
+ f"cohort_id={cohort.id})"
+ )
+
+ print()
+
+ print("Tasks:")
+
+ print(
+ f" Task {task1.id}: {task1.title}"
+ )
+
+ for subtask in task1_subtasks:
+ print(
+ f" - {subtask.title}"
+ )
+
+ print()
+
+ print(
+ f" Task {task2.id}: {task2.title}"
+ )
+
+ for subtask in task2_subtasks:
+ print(
+ f" - {subtask.title}"
+ )
+
+ print()
+
+ print("Assignments:")
+ print(" Both tasks assigned to both trainees.")
+
+ print()
+ print("Sample progress:")
+ print(" Trainee 1 -> Task 1 -> 33%")
+ print(" Trainee 1 -> Task 1 -> first subtask completed")
+ print()
+
+
+def run() -> None:
+ db = SessionLocal()
+
+ try:
+ clear_database(db)
+ seed_database(db)
+
+ except Exception:
+ db.rollback()
+ raise
+
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.gitignore b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.oxlintrc.json b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.oxlintrc.json
new file mode 100644
index 00000000..12550782
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/README.md b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/README.md
new file mode 100644
index 00000000..d937833b
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the Oxlint configuration
+
+If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/index.html b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/index.html
new file mode 100644
index 00000000..9241e0ff
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ task-board-frontend
+
+
+
+
+
+
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package-lock.json b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package-lock.json
new file mode 100644
index 00000000..397902c6
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package-lock.json
@@ -0,0 +1,1416 @@
+{
+ "name": "task-board-frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "task-board-frontend",
+ "version": "0.0.0",
+ "dependencies": {
+ "@reduxjs/toolkit": "^2.12.0",
+ "framer-motion": "^13.1.1",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-redux": "^9.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "oxlint": "^1.79.0",
+ "vite": "^8.2.2"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz",
+ "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz",
+ "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz",
+ "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz",
+ "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz",
+ "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz",
+ "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz",
+ "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz",
+ "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz",
+ "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz",
+ "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz",
+ "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz",
+ "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz",
+ "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz",
+ "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz",
+ "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz",
+ "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz",
+ "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz",
+ "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz",
+ "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
+ "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "13.1.1",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz",
+ "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^13.1.1",
+ "motion-utils": "^13.0.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/immer": {
+ "version": "11.1.18",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
+ "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "13.1.1",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz",
+ "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^13.0.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
+ "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz",
+ "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.80.0",
+ "@oxlint/binding-android-arm64": "1.80.0",
+ "@oxlint/binding-darwin-arm64": "1.80.0",
+ "@oxlint/binding-darwin-x64": "1.80.0",
+ "@oxlint/binding-freebsd-x64": "1.80.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.80.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.80.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.80.0",
+ "@oxlint/binding-linux-arm64-musl": "1.80.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.80.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.80.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.80.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.80.0",
+ "@oxlint/binding-linux-x64-gnu": "1.80.0",
+ "@oxlint/binding-linux-x64-musl": "1.80.0",
+ "@oxlint/binding-openharmony-arm64": "1.80.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.80.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.80.0",
+ "@oxlint/binding-win32-x64-msvc": "1.80.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2001",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
+ "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
+ "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package.json b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package.json
new file mode 100644
index 00000000..8d3c8bf8
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "task-board-frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "oxlint",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@reduxjs/toolkit": "^2.12.0",
+ "framer-motion": "^13.1.1",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-redux": "^9.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "oxlint": "^1.79.0",
+ "vite": "^8.2.2"
+ }
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/favicon.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/favicon.svg
new file mode 100644
index 00000000..6893eb13
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/icons.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/icons.svg
new file mode 100644
index 00000000..e9522193
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.css
new file mode 100644
index 00000000..1869651d
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.css
@@ -0,0 +1,60 @@
+/* Main application layout */
+
+.app-layout {
+ display: flex;
+ width: 100%;
+ min-height: 100vh;
+ background: var(--bg);
+}
+
+/* Sidebar stays visible while the right side scrolls */
+
+.app-layout > aside {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ flex-shrink: 0;
+ overflow-y: auto;
+}
+
+/* Right side of the sidebar */
+
+.app-main {
+ flex: 1;
+ min-width: 0;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Main page content */
+
+.main-content {
+ flex: 1;
+ padding: 32px;
+ background: var(--bg);
+ box-sizing: border-box;
+}
+
+/* Mobile */
+
+@media (max-width: 768px) {
+ .app-layout {
+ flex-direction: column;
+ }
+
+ .app-layout > aside {
+ position: static;
+ width: 100%;
+ height: auto;
+ overflow: visible;
+ }
+
+ .app-main {
+ width: 100%;
+ }
+
+ .main-content {
+ padding: 20px;
+ }
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.jsx
new file mode 100644
index 00000000..2f5b971c
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.jsx
@@ -0,0 +1,151 @@
+import { useEffect } from "react";
+import { useDispatch, useSelector } from "react-redux";
+
+import Login from "./components/Login";
+
+import TraineeHeader from "./components/Trainee/TraineeHeader";
+import TraineeSidebar from "./components/Trainee/TraineeSidebar";
+import TraineeTasks from "./components/Trainee/TraineeTasks";
+
+import TrainerHeader from "./components/Trainer/TrainerHeader";
+import TrainerSidebar from "./components/Trainer/TrainerSidebar";
+import TrainerTasks from "./components/Trainer/TrainerTasks";
+import TrainerCohorts from "./components/Trainer/TrainerCohorts";
+import TrainerTrainees from "./components/Trainer/TrainerTrainees";
+
+import { logout, setName as setTraineeName } from "./store/TraineeSlice";
+import { setName as setTrainerName } from "./store/TrainerSlice";
+
+import useTheme from "./hooks/useTheme";
+
+import "./App.css";
+
+const API_BASE_URL =
+ import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api";
+
+function App() {
+ const dispatch = useDispatch();
+
+ // ============================================================
+ // Authentication
+ // ============================================================
+
+ const { accessToken, tokenType, role, isAuthenticated } = useSelector(
+ (state) => state.trainee,
+ );
+
+ // ============================================================
+ // Theme (light/dark) - shared regardless of role, toggled from
+ // whichever sidebar is showing
+ // ============================================================
+
+ const { theme, toggleTheme } = useTheme();
+
+ // ============================================================
+ // Trainer navigation
+ // ============================================================
+
+ const trainerTab = useSelector((state) => state.trainer.tab);
+
+ // ============================================================
+ // GET /api/auth/me
+ // ============================================================
+
+ useEffect(() => {
+ if (!isAuthenticated || !accessToken) return;
+
+ async function fetchProfile() {
+ try {
+ const response = await fetch(`${API_BASE_URL}/auth/me`, {
+ headers: {
+ Authorization: `${tokenType || "Bearer"} ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) return;
+
+ const data = await response.json();
+
+ if (data.role === "manager") {
+ dispatch(setTrainerName(data.fullName));
+ } else {
+ dispatch(setTraineeName(data.fullName));
+ }
+ } catch (err) {
+ console.error("Error fetching profile:", err);
+ }
+ }
+
+ fetchProfile();
+ }, [isAuthenticated, accessToken, tokenType, dispatch]);
+
+ // ============================================================
+ // Logout
+ // ============================================================
+
+ function handleLogout() {
+ dispatch(logout());
+ }
+
+ // ============================================================
+ // Login
+ // ============================================================
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ // ============================================================
+ // Determine role
+ // ============================================================
+
+ const isManager = role === "manager";
+
+ // ============================================================
+ // Trainer content
+ // ============================================================
+
+ function renderTrainerContent() {
+ switch (trainerTab) {
+ case "cohorts":
+ return ;
+
+ case "trainees":
+ return ;
+
+ case "tasks":
+ return ;
+
+ default:
+ return ;
+ }
+ }
+
+ // ============================================================
+ // Render
+ // ============================================================
+
+ return (
+
+ {isManager ? (
+
+ ) : (
+
+ )}
+
+
+ {isManager ? : }
+
+
+ {isManager ? renderTrainerContent() : }
+
+
+
+ );
+}
+
+export default App;
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/hero.png b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/hero.png
new file mode 100644
index 00000000..02251f4b
Binary files /dev/null and b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/hero.png differ
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/react.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/react.svg
new file mode 100644
index 00000000..6c87de9b
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/vite.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/vite.svg
new file mode 100644
index 00000000..5101b674
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.jsx
new file mode 100644
index 00000000..1884f016
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.jsx
@@ -0,0 +1,201 @@
+import { useState } from "react";
+import { useDispatch } from "react-redux";
+import styles from "./Login.module.css";
+
+import { setCredentials } from "../store/TraineeSlice";
+
+const API_BASE_URL =
+ import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api";
+
+export default function Login({ onLogin }) {
+ const dispatch = useDispatch();
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ console.log("========== LOGIN REQUEST ==========");
+
+ const response = await fetch(`${API_BASE_URL}/auth/login`, {
+ method: "POST",
+
+ headers: {
+ "Content-Type": "application/json",
+ },
+
+ body: JSON.stringify({
+ email,
+ password,
+ }),
+ });
+
+ console.log("Status:", response.status);
+ console.log("OK:", response.ok);
+
+ const data = await response.json();
+
+ console.log("Response Data:", data);
+
+ // --------------------------------
+ // Handle errors
+ // --------------------------------
+
+ if (!response.ok) {
+ if (Array.isArray(data.detail)) {
+ const messages = data.detail
+ .map((validationError) => {
+ const field =
+ validationError.loc?.[validationError.loc.length - 1];
+
+ const message = validationError.msg || "Invalid value";
+
+ return field ? `${field}: ${message}` : message;
+ })
+ .join(", ");
+
+ throw new Error(messages);
+ }
+
+ if (typeof data.detail === "string") {
+ throw new Error(data.detail);
+ }
+
+ throw new Error(`Login failed with status ${response.status}`);
+ }
+
+ // --------------------------------
+ // Validate login response
+ // --------------------------------
+
+ if (!data.accessToken) {
+ throw new Error("Login succeeded, but no access token was returned.");
+ }
+
+ if (!data.role) {
+ throw new Error("Login succeeded, but no role was returned.");
+ }
+
+ if (!data.userId) {
+ throw new Error("Login succeeded, but no user ID was returned.");
+ }
+
+ // --------------------------------
+ // Save login information to Redux
+ // --------------------------------
+
+ dispatch(
+ setCredentials({
+ accessToken: data.accessToken,
+ tokenType: data.tokenType,
+ role: data.role,
+ userId: data.userId,
+ }),
+ );
+
+ console.log("========== LOGIN SUCCESS ==========");
+ console.log("User ID:", data.userId);
+ console.log("Role:", data.role);
+ console.log("Token Type:", data.tokenType);
+ console.log("Access Token Received:", !!data.accessToken);
+ console.log("===================================");
+
+ // --------------------------------
+ // Tell App.jsx login succeeded
+ // --------------------------------
+
+ if (onLogin) {
+ onLogin();
+ }
+ } catch (error) {
+ console.error("========== LOGIN ERROR ==========");
+ console.error(error);
+ console.error("=================================");
+
+ setError(error instanceof Error ? error.message : "Unable to log in.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
C.B.M.H.
+
+
Welcome Back
+
+
Sign in to access your trainee task board.
+
+
+ {/* Login Form */}
+
+
+
+
+ );
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.module.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.module.css
new file mode 100644
index 00000000..785568d0
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.module.css
@@ -0,0 +1,105 @@
+/* Deliberately not themed - the login screen always renders light,
+ regardless of whichever theme was last toggled (which persists in
+ localStorage independent of being logged in). Colors below are the
+ light-theme token values from theme.css, hardcoded rather than
+ referenced via var(), so a returning dark-mode user doesn't land
+ on a dark login page before they've even signed in. */
+
+.loginContainer {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ background: #f2ead9;
+}
+
+.loginCard {
+ width: 100%;
+ max-width: 420px;
+ padding: 2.5rem;
+ border: 1px solid #d6c4a1;
+ border-radius: 12px;
+ background: #fdf9f0;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
+}
+
+.header {
+ margin-bottom: 2rem;
+ text-align: center;
+}
+
+.label {
+ margin: 0 0 0.5rem;
+ color: #6d7f45;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+}
+
+.header h1 {
+ margin: 0 0 0.5rem;
+ color: #241a10;
+}
+
+.header p:last-child {
+ margin: 0;
+ color: #7c6a51;
+}
+
+.inputGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 1.25rem;
+}
+
+.inputGroup label {
+ color: #3b2f22;
+ font-weight: 600;
+}
+
+.inputGroup input {
+ padding: 0.8rem;
+ border: 1px solid #d6c4a1;
+ border-radius: 6px;
+ background: #fdf9f0;
+ color: #3b2f22;
+ font-size: 1rem;
+}
+
+.inputGroup input:focus {
+ outline: none;
+ border-color: #6d7f45;
+}
+
+/* Only buttons inside this Login component */
+.loginCard button {
+ width: 100%;
+ padding: 0.85rem;
+ border: none;
+ border-radius: 6px;
+ background: #6d7f45;
+ color: white;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.loginCard button:hover {
+ background: #576636;
+}
+
+.loginCard button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.error {
+ margin-bottom: 1rem;
+ padding: 0.75rem;
+ border: 1px solid #d3a293;
+ border-radius: 6px;
+ background: #f3e0da;
+ color: #a13f34;
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.jsx
new file mode 100644
index 00000000..1ef815f1
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.jsx
@@ -0,0 +1,286 @@
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { useDispatch, useSelector } from "react-redux";
+import styles from "./ProfileModal.module.css";
+
+import { setName as setTraineeName } from "../store/TraineeSlice";
+import { setName as setTrainerName } from "../store/TrainerSlice";
+
+const API_BASE_URL =
+ import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api";
+
+// Backend only stores a single full_name column - split/join here
+// rather than migrating to first_name/last_name, which would touch
+// every place that already reads full_name (task progress views,
+// trainee lists, the seed script, etc).
+function splitName(fullName) {
+ const trimmed = (fullName || "").trim();
+
+ if (!trimmed) {
+ return { firstName: "", lastName: "" };
+ }
+
+ const [first, ...rest] = trimmed.split(" ");
+
+ return { firstName: first, lastName: rest.join(" ") };
+}
+
+export default function ProfileModal({ onClose }) {
+ const dispatch = useDispatch();
+
+ const { accessToken, tokenType, role } = useSelector(
+ (state) => state.trainee,
+ );
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ const [firstName, setFirstName] = useState("");
+ const [lastName, setLastName] = useState("");
+ const [email, setEmail] = useState("");
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+
+ function getAuthHeaders() {
+ return {
+ Authorization: `${tokenType || "Bearer"} ${accessToken}`,
+ "Content-Type": "application/json",
+ };
+ }
+
+ useEffect(() => {
+ async function fetchProfile() {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await fetch(`${API_BASE_URL}/auth/me`, {
+ headers: getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to load your profile.");
+ }
+
+ const data = await response.json();
+ const { firstName: fn, lastName: ln } = splitName(data.fullName);
+
+ setFirstName(fn);
+ setLastName(ln);
+ setEmail(data.email || "");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to load profile.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ fetchProfile();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+
+ setError(null);
+ setSuccess(false);
+
+ if (newPassword && newPassword !== confirmPassword) {
+ setError("New password and confirmation don't match.");
+ return;
+ }
+
+ if (newPassword && !currentPassword) {
+ setError("Enter your current password to set a new one.");
+ return;
+ }
+
+ try {
+ setSaving(true);
+
+ const payload = {
+ fullName: `${firstName.trim()} ${lastName.trim()}`.trim(),
+ email: email.trim(),
+ };
+
+ if (newPassword) {
+ payload.currentPassword = currentPassword;
+ payload.newPassword = newPassword;
+ }
+
+ const response = await fetch(`${API_BASE_URL}/auth/me`, {
+ method: "PUT",
+ headers: getAuthHeaders(),
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.detail || "Failed to update your profile.");
+ }
+
+ if (role === "manager") {
+ dispatch(setTrainerName(data.fullName));
+ } else {
+ dispatch(setTraineeName(data.fullName));
+ }
+
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setSuccess(true);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Unable to update your profile.",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return createPortal(
+
+
event.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="profile-modal-title"
+ >
+
+
Profile Settings
+
+
+
+
+ {loading ? (
+
Loading your profile...
+ ) : (
+
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.module.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.module.css
new file mode 100644
index 00000000..703002d7
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.module.css
@@ -0,0 +1,190 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ padding: 1.5rem;
+
+ background: rgba(15, 23, 42, 0.5);
+}
+
+.modal {
+ width: 100%;
+ max-width: 480px;
+ max-height: 90vh;
+ overflow-y: auto;
+
+ padding: 1.75rem;
+
+ border: 1px solid var(--border);
+ border-radius: 12px;
+
+ background: var(--surface);
+ color: var(--text);
+
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1.25rem;
+}
+
+.header h2 {
+ margin: 0;
+ color: var(--text-strong);
+ font-size: 1.3rem;
+}
+
+.closeButton {
+ padding: 0.25rem 0.6rem;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.closeButton:hover {
+ background: var(--surface-alt);
+ color: var(--text);
+}
+
+.loading {
+ margin: 0;
+ color: var(--text-muted);
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.field label {
+ color: var(--text);
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.field input {
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 0.9rem;
+ font-family: inherit;
+}
+
+.field input:focus {
+ outline: none;
+ border-color: var(--accent-trainer);
+}
+
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+
+ margin: 0.25rem 0;
+
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.divider::before,
+.divider::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+
+.error {
+ padding: 0.65rem 0.8rem;
+ border: 1px solid var(--danger-border);
+ border-radius: 6px;
+ background: var(--danger-bg);
+ color: var(--danger);
+ font-size: 0.85rem;
+}
+
+.success {
+ padding: 0.65rem 0.8rem;
+ border: 1px solid var(--accent-trainee);
+ border-radius: 6px;
+ background: var(--surface-alt);
+ color: var(--accent-trainee);
+ font-size: 0.85rem;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ margin-top: 0.25rem;
+}
+
+.cancelButton {
+ padding: 0.6rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.cancelButton:hover {
+ background: var(--surface-alt);
+}
+
+.saveButton {
+ padding: 0.6rem 1.3rem;
+ border: none;
+ border-radius: 6px;
+ background: var(--accent-trainer);
+ color: #ffffff;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.saveButton:hover {
+ background: var(--accent-trainer-hover);
+}
+
+.saveButton:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+@media (max-width: 480px) {
+ .row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.jsx
new file mode 100644
index 00000000..b6250abc
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.jsx
@@ -0,0 +1,35 @@
+import styles from "./TraineeHeader.module.css";
+
+import { useSelector } from "react-redux";
+
+export default function TraineeHeader() {
+ const trainee = useSelector((state) => state.trainee);
+
+ const traineeName = trainee.name;
+ const userId = trainee.userId;
+
+ return (
+
+
+
NEW TRAINEE
+
+
+ Welcome to C.B.M.H.
+
+
+
+ {traineeName
+ ? `Welcome, ${traineeName}!`
+ : userId
+ ? `Welcome, Trainee #${userId}!`
+ : "Welcome!"}
+
+
+
+ Your trainee task board is ready. View your tasks, track your
+ progress, and stay organized throughout your training.
+
+
+
+ );
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.module.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.module.css
new file mode 100644
index 00000000..d514ce99
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.module.css
@@ -0,0 +1,48 @@
+.traineeHeader {
+ width: 100%;
+ padding: 2rem;
+ border-radius: 12px;
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ box-sizing: border-box;
+}
+
+.headerContent {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.welcomeLabel {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+.traineeHeader h1 {
+ margin: 0;
+ color: var(--text-strong);
+ font-size: 2rem;
+ font-weight: 700;
+}
+
+.traineeHeader h1 span {
+ color: var(--accent-trainee);
+}
+
+.traineeName {
+ margin: 0.25rem 0 0;
+ color: var(--text);
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.description {
+ max-width: 650px;
+ margin: 0.25rem 0 0;
+ color: var(--text-muted);
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeSidebar.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeSidebar.css
new file mode 100644
index 00000000..334c897b
--- /dev/null
+++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeSidebar.css
@@ -0,0 +1,169 @@
+.trainee-sidebar {
+ width: 260px;
+ height: 100vh;
+ background: var(--sidebar-bg);
+ color: var(--sidebar-text);
+ display: flex;
+ flex-direction: column;
+ padding: 24px 16px;
+ box-sizing: border-box;
+}
+
+.sidebar-header {
+ padding: 0 12px 24px;
+ border-bottom: 1px solid var(--sidebar-border);
+}
+
+.sidebar-header h2 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 700;
+ text-transform: capitalize;
+}
+
+/* Trainee profile */
+
+.trainee-profile {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 24px 12px;
+ border-bottom: 1px solid var(--sidebar-border);
+}
+
+.trainee-avatar {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ background: var(--accent-trainer);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.trainee-details {
+ min-width: 0;
+}
+
+.trainee-details h3 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.trainee-details span {
+ display: block;
+ margin-top: 3px;
+ color: var(--sidebar-text-muted);
+ font-size: 13px;
+}
+
+/* Navigation */
+
+.sidebar-navigation {
+ flex: 1;
+ padding-top: 24px;
+}
+
+.navigation-title {
+ margin: 0 12px 10px;
+ color: var(--sidebar-text-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+.sidebar-link {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 11px 12px;
+ margin-bottom: 5px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--sidebar-text);
+ text-decoration: none;
+ font-family: inherit;
+ font-size: 14px;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+/* Reset native