diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..e9cbfb6 --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,54 @@ +# LeoCRM Deployment Guide + +## Coolify Deployment + +### Application Info +- **Coolify App UUID:** `stvabl4vaqru7jclx4ittzr3` +- **App ID:** 12 +- **Domain:** `https://crm.media-on.de` +- **Git Repo:** `https://forgejo.media-on.de/Leopoldadmin/leocrm.git` +- **Branch:** `main` +- **Build Pack:** Dockerfile (Multi-Stage: Frontend + Backend) +- **Health Check:** `GET /api/v1/health` → 200 + +### Deploy Process +1. **Code pushen:** `git push origin main` +2. **Coolify Deploy triggern:** + - Option A: Coolify Dashboard → `https://server.media-on.de` → App → Deploy + - Option B: API: `POST https://server.media-on.de/api/v1/deploy` mit `{"uuid": "stvabl4vaqru7jclx4ittzr3"}` +3. **Build dauert ~2-5 Min** (Multi-Stage: npm install + vite build + pip install + runtime) +4. **Container wird automatisch ausgetauscht** wenn Build erfolgreich + +### Manueller Build (Fallback) +Wenn Coolify den Build nicht ausführt: +```bash +ssh root@46.225.91.159 +cd /tmp && git clone https://Leopoldadmin:@forgejo.media-on.de/Leopoldadmin/leocrm.git leocrm-build +cd leocrm-build +docker build -t stvabl4vaqru7jclx4ittzr3: . +cd /data/coolify/applications/stvabl4vaqru7jclx4ittzr3 +# docker-compose.yaml Image-Tag aktualisieren +# .env SOURCE_COMMIT aktualisieren +docker compose up -d +docker stop && docker rm +``` + +### Bekannte Probleme +- **Build schlägt fehl bei JSX/TS-Fehlern:** Vite-Build bricht ab → Coolify zeigt FAIL nach ~30s +- **docker-compose.yaml wird VOR dem Build aktualisiert:** Wenn Build fehlschlägt, bleibt nicht-existentes Image in der Config +- **Coolify Queue kann hängen:** Manchmal wird Deploy gequeued aber nicht verarbeitet — dann manuell bauen + +### Pre-Deploy Checklist +- [ ] `npx tsc --noEmit` — keine neuen TSC-Fehler (pre-existing Dms.tsx errors sind OK) +- [ ] `npx vite build` — Frontend-Build erfolgreich +- [ ] `git push origin main` — Code auf Forgejo +- [ ] Coolify Deploy triggern +- [ ] Health-Check: `curl https://crm.media-on.de/api/v1/health` + +### Server Info +- **Host:** `46.225.91.159` (coolify-01) +- **SSH Key:** `/a0/usr/workdir/.ssh/coolify-01-root` +- **Coolify API Token:** `2|UnMMp2WYbXFJCrOuZ1yqSK96hMooPTAfAJPIXiQc1e47154e` +- **Coolify Dashboard:** `https://server.media-on.de` +- **PostgreSQL:** `crm-postgres` container (pgvector/pgvector:pg16) +- **Redis:** `crm-redis` container diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e64c68..282242a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.56.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.8", "@tiptap/extension-color": "^3.28.0", "@tiptap/extension-image": "^3.28.0", "@tiptap/extension-link": "^3.28.0", @@ -52,6 +53,7 @@ "tailwindcss": "^3.4.13", "typescript": "^5.6.0", "vite": "^5.4.0", + "vite-bundle-visualizer": "^1.2.1", "vitest": "^2.1.0" } }, @@ -1391,6 +1393,22 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz", + "integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==", + "dependencies": { + "@tanstack/virtual-core": "3.17.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -1403,6 +1421,15 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz", + "integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2618,6 +2645,84 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2800,6 +2905,15 @@ "node": ">=6" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3178,6 +3292,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3499,6 +3622,29 @@ "node": ">=4" } }, + "node_modules/import-from-esm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", + "integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=16.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3571,6 +3717,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3636,6 +3797,18 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4887,6 +5060,23 @@ "node": ">= 6" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", @@ -5595,6 +5785,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5670,6 +5869,48 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", + "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", + "dev": true, + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rope-sequence": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", @@ -5778,6 +6019,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6165,6 +6415,15 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6448,6 +6707,24 @@ } } }, + "node_modules/vite-bundle-visualizer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/vite-bundle-visualizer/-/vite-bundle-visualizer-1.2.1.tgz", + "integrity": "sha512-cwz/Pg6+95YbgIDp+RPwEToc4TKxfsFWSG/tsl2DSZd9YZicUag1tQXjJ5xcL7ydvEoaC2FOZeaXOU60t9BRXw==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "import-from-esm": "^1.3.3", + "rollup-plugin-visualizer": "^5.11.0", + "tmp": "^0.2.1" + }, + "bin": { + "vite-bundle-visualizer": "bin.js" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, "node_modules/vite-node": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", @@ -6765,12 +7042,80 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/frontend/package.json b/frontend/package.json index 25e6921..1147a09 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.56.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.8", "@tiptap/extension-color": "^3.28.0", "@tiptap/extension-image": "^3.28.0", "@tiptap/extension-link": "^3.28.0", @@ -56,6 +57,7 @@ "tailwindcss": "^3.4.13", "typescript": "^5.6.0", "vite": "^5.4.0", + "vite-bundle-visualizer": "^1.2.1", "vitest": "^2.1.0" } } diff --git a/frontend/src/components/contacts/ContactList.tsx b/frontend/src/components/contacts/ContactList.tsx index a1c6a92..bedbc8b 100644 --- a/frontend/src/components/contacts/ContactList.tsx +++ b/frontend/src/components/contacts/ContactList.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useRef } from 'react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { Badge } from '@/components/ui/Badge'; import { Pagination } from '@/components/ui/Pagination'; import { EmptyState } from '@/components/ui/EmptyState'; @@ -75,6 +76,18 @@ export function ContactList({ onSortChange, }: ContactListProps) { const { t } = useTranslation(); + const scrollRef = useRef(null); + + // Auto-skip virtualization for small datasets + const shouldVirtualize = contacts.length >= 50; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? contacts.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => viewMode === 'cards' ? 120 : 56, + overscan: 8, + enabled: shouldVirtualize, + }); if (loading) { return ( @@ -105,43 +118,67 @@ export function ContactList({ // ── List View ── if (viewMode === 'list') { + const renderContactItem = (contact: UnifiedContact) => ( +
  • + +
  • + ); + return (
    -
    -
      - {contacts.map((contact) => ( -
    • - -
    • - ))} -
    + ); + })} + + ) : ( +
      + {contacts.map((contact) => renderContactItem(contact))} +
    + )}
    ( + { + e.dataTransfer.setData('text/plain', contact.id); + e.dataTransfer.effectAllowed = 'move'; + }} + onClick={() => onSelectContact(contact)} + className={clsx( + 'cursor-pointer transition-colors', + selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50', + )} + aria-current={selectedContactId === contact.id ? 'true' : undefined} + > + e.stopPropagation()}> + + + + {getDisplayName(contact)} + {getEmail(contact)} + {getPhone(contact)} + {getCity(contact)} + +
    + {getTags(contact).slice(0, 3).map((tag) => ( + {tag} + ))} +
    + + + ); + return (
    -
    +
    - + - {contacts.map((contact) => ( - { - e.dataTransfer.setData('text/plain', contact.id); - e.dataTransfer.effectAllowed = 'move'; - }} - onClick={() => onSelectContact(contact)} - className={clsx( - 'cursor-pointer transition-colors', - selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50', - )} - aria-current={selectedContactId === contact.id ? 'true' : undefined} - > - - - - - - - - - ))} + {shouldVirtualize ? ( + <> + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const contact = contacts[virtualRow.index]; + return ( + + {virtualRow.index === 0 && ( + + + )} + {renderContactRow(contact)} + {virtualRow.index === rowVirtualizer.getVirtualItems().length - 1 && ( + + + )} + + ); + })} + + ) : ( + contacts.map((contact) => renderContactRow(contact)) + )}
    @@ -202,38 +273,30 @@ export function ContactList({
    e.stopPropagation()}> - - {getDisplayName(contact)}{getEmail(contact)}{getPhone(contact)}{getCity(contact)} -
    - {getTags(contact).slice(0, 3).map((tag) => ( - {tag} - ))} -
    -
    +
    +
    @@ -249,48 +312,74 @@ export function ContactList({ } // ── Cards View ── + const renderContactCard = (contact: UnifiedContact) => ( +
    { + e.dataTransfer.setData('text/plain', contact.id); + e.dataTransfer.effectAllowed = 'move'; + }} + onClick={() => onSelectContact(contact)} + className={clsx( + 'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch', + selectedContactId === contact.id + ? 'border-primary-300 bg-primary-50' + : 'border-secondary-200 bg-white hover:bg-secondary-50', + )} + role="button" + tabIndex={0} + aria-current={selectedContactId === contact.id ? 'true' : undefined} + > +
    +
    + {getInitials(contact)} +
    +
    +
    + {getDisplayName(contact)} +
    + +
    +
    +
    +
    {getEmail(contact)}
    +
    {getPhone(contact)}
    +
    {getCity(contact)}
    +
    +
    + ); + return (
    -
    -
    - {contacts.map((contact) => ( -
    { - e.dataTransfer.setData('text/plain', contact.id); - e.dataTransfer.effectAllowed = 'move'; - }} - onClick={() => onSelectContact(contact)} - className={clsx( - 'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch', - selectedContactId === contact.id - ? 'border-primary-300 bg-primary-50' - : 'border-secondary-200 bg-white hover:bg-secondary-50', - )} - role="button" - tabIndex={0} - aria-current={selectedContactId === contact.id ? 'true' : undefined} - > -
    -
    - {getInitials(contact)} -
    -
    -
    - {getDisplayName(contact)} +
    + {shouldVirtualize ? ( +
    +
    + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const contact = contacts[virtualRow.index]; + return ( +
    + {renderContactCard(contact)}
    - -
    -
    -
    -
    {getEmail(contact)}
    -
    {getPhone(contact)}
    -
    {getCity(contact)}
    -
    + ); + })}
    - ))} -
    +
    + ) : ( +
    + {contacts.map((contact) => renderContactCard(contact))} +
    + )}
    (null); + const shouldVirtualize = sortedFiles.length >= 50; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? sortedFiles.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => 44, + overscan: 10, + enabled: shouldVirtualize, + }); + if (loading) { return (
    @@ -204,8 +216,41 @@ export function FileExplorer({ // ─── List View ─── if (viewMode === 'list') { + const renderFileRow = (file: DmsFile) => { + const icon = getFileIcon(file.mime_type); + const isSelected = selectedFileIds.has(file.id); + const isActive = selectedFile?.id === file.id; + return ( +
  • +
    onFileClick(file)} + onDoubleClick={() => onFileDoubleClick(file)} + data-testid={`file-row-${file.id}`} + > + onToggleSelect(file.id)} + onClick={(e) => e.stopPropagation()} + className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0" + aria-label={t('dms.bulkSelect')} + /> +
    +
  • + ); + }; + return ( -
    +
    {/* Sort header */}
    @@ -214,48 +259,79 @@ export function FileExplorer({ |
    -
      - {sortedFiles.map((file) => { - const icon = getFileIcon(file.mime_type); - const isSelected = selectedFileIds.has(file.id); - const isActive = selectedFile?.id === file.id; - return ( -
    • + {shouldVirtualize ? ( +
        + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const file = sortedFiles[virtualRow.index]; + return (
        onFileClick(file)} - onDoubleClick={() => onFileDoubleClick(file)} - data-testid={`file-row-${file.id}`} + key={file.id} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + transform: `translateY(${virtualRow.start}px)`, + }} > - onToggleSelect(file.id)} - onClick={(e) => e.stopPropagation()} - className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0" - aria-label={t('dms.bulkSelect')} - /> -
        - - ); - })} -
      + ); + })} +
    + ) : ( +
      + {sortedFiles.map((file) => renderFileRow(file))} +
    + )}
    ); } // ─── Table View ─── if (viewMode === 'table') { + const renderFileRow = (file: DmsFile) => { + const icon = getFileIcon(file.mime_type); + const isSelected = selectedFileIds.has(file.id); + const isActive = selectedFile?.id === file.id; + return ( + onFileClick(file)} + onDoubleClick={() => onFileDoubleClick(file)} + data-testid={`file-row-${file.id}`} + > + e.stopPropagation()}> + onToggleSelect(file.id)} + className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + aria-label={t('dms.bulkSelect')} + /> + + +
    +
    + + {formatFileSize(getFileSize(file))} + {file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')} + {formatDate(file.updated_at || file.created_at)} + e.stopPropagation()}> + + + + ); + }; + return ( -
    +
    @@ -283,45 +359,30 @@ export function FileExplorer({ - {sortedFiles.map((file) => { - const icon = getFileIcon(file.mime_type); - const isSelected = selectedFileIds.has(file.id); - const isActive = selectedFile?.id === file.id; - return ( - onFileClick(file)} - onDoubleClick={() => onFileDoubleClick(file)} - data-testid={`file-row-${file.id}`} - > - - - - - - - - ); - })} + {shouldVirtualize ? ( + <> + {rowVirtualizer.getVirtualItems().map((virtualRow, idx) => { + const file = sortedFiles[virtualRow.index]; + return ( + + {idx === 0 && virtualRow.start > 0 && ( + + + )} + {renderFileRow(file)} + {idx === rowVirtualizer.getVirtualItems().length - 1 && ( + + + )} + + ); + })} + + ) : ( + sortedFiles.map((file) => renderFileRow(file)) + )}
    e.stopPropagation()}> - onToggleSelect(file.id)} - className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" - aria-label={t('dms.bulkSelect')} - /> - -
    -
    -
    {formatFileSize(getFileSize(file))}{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}{formatDate(file.updated_at || file.created_at)} e.stopPropagation()}> - -
    +
    +
    diff --git a/frontend/src/components/dms/FileGrid.tsx b/frontend/src/components/dms/FileGrid.tsx index 0acfc06..895dc24 100644 --- a/frontend/src/components/dms/FileGrid.tsx +++ b/frontend/src/components/dms/FileGrid.tsx @@ -3,9 +3,10 @@ * Displays files with icons, names, sizes, and action buttons. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { DmsFile } from '@/api/dms'; import { Eye, FileText, Share2, Trash2 } from 'lucide-react'; @@ -33,11 +34,22 @@ export function FileGrid({ loading = false, }: FileGridProps) { const { t } = useTranslation(); + const scrollRef = useRef(null); const sortedFiles = useMemo(() => { return [...files].sort((a, b) => a.name.localeCompare(b.name)); }, [files]); + const shouldVirtualize = sortedFiles.length >= 50; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? sortedFiles.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => 140, + overscan: 8, + enabled: shouldVirtualize, + }); + if (loading) { return (
    @@ -62,67 +74,148 @@ export function FileGrid({ } return ( -
    - {sortedFiles.map((file) => { - const icon = getFileIcon(file.mime_type); - const isSelected = selectedFileIds.has(file.id); - return ( -
    onFileClick(file)} - data-testid={`file-card-${file.id}`} - > -
    -
    - onToggleSelect(file.id)} - onClick={(e) => e.stopPropagation()} - className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" - aria-label={t('dms.bulkSelect')} - /> -
    ); } diff --git a/frontend/src/components/mail/MailList.tsx b/frontend/src/components/mail/MailList.tsx index 7eacfc3..dc57a55 100644 --- a/frontend/src/components/mail/MailList.tsx +++ b/frontend/src/components/mail/MailList.tsx @@ -1,11 +1,12 @@ /** - * Mail list component with pagination. + * Mail list component with pagination and virtual scrolling. * Shows list of mails in selected folder with seen/flagged indicators. */ -import React from 'react'; +import React, { useRef } from 'react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { Mail } from '@/api/mail'; import { decodeMimeHeader } from '@/api/mail'; import { EmptyState } from '@/components/ui/EmptyState'; @@ -53,6 +54,18 @@ export function MailList({ const { t } = useTranslation(); const totalPages = Math.ceil(total / pageSize); const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id)); + const scrollRef = useRef(null); + + // Auto-skip virtualization for small datasets + const shouldVirtualize = mails.length >= 50; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? mails.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => 80, // approx mail item height + overscan: 8, + enabled: shouldVirtualize, + }); const handleSortFieldChange = (e: React.ChangeEvent) => { const newSortBy = e.target.value as 'date' | 'from' | 'subject'; @@ -83,6 +96,75 @@ export function MailList({ ); } + const renderMailItem = (mail: Mail) => ( +
  • +
    +
    + onToggleSelect(mail.id)} + className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + aria-label={t('mail.selectMail')} + data-testid={`mail-checkbox-${mail.id}`} + /> +
    + +
    +
  • + ); + return (
    {/* Select-all + Sort header in one row */} @@ -133,76 +215,45 @@ export function MailList({ )}
    {/* Mail list — scrolls, pagination stays fixed */} -
      - {mails.map((mail) => ( -
    • -
      -
      - onToggleSelect(mail.id)} - className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" - aria-label={t('mail.selectMail')} - data-testid={`mail-checkbox-${mail.id}`} - /> -
      - -
      + ); + })}
    • - ))} -
    + + ) : ( +
      + {mails.map((mail) => renderMailItem(mail))} +
    + )} {totalPages > 1 && (
    { globalFilter?: string; onGlobalFilterChange?: (value: string) => void; testId?: string; + virtualized?: boolean; } export function DataGrid>({ @@ -44,9 +46,11 @@ export function DataGrid>({ loading = false, enableSorting = true, testId, + virtualized = true, }: DataGridProps) { const { t } = useTranslation(); const [sorting, setSorting] = useState([]); + const scrollRef = useRef(null); const table = useReactTable({ data, @@ -60,11 +64,59 @@ export function DataGrid>({ const totalPages = Math.ceil(total / pageSize); + // Auto-skip virtualization for small datasets + const shouldVirtualize = virtualized && data.length >= 50; + + const rows = table.getRowModel().rows; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? rows.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => 53, // approx row height + overscan: 10, + enabled: shouldVirtualize, + }); + + const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : []; + const totalHeight = shouldVirtualize ? rowVirtualizer.getTotalSize() : 0; + const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; + const paddingBottom = virtualRows.length > 0 ? totalHeight - virtualRows[virtualRows.length - 1].end : 0; + + const renderRow = useCallback( + (row: typeof rows[number]) => ( + onRowClick(row.original) : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={onRowClick ? (e) => { + if (e.key === 'Enter') onRowClick(row.original); + } : undefined} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ), + [onRowClick] + ); + return (
    -
    +
    - + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { @@ -114,33 +166,31 @@ export function DataGrid>({ - ) : table.getRowModel().rows.length === 0 ? ( + ) : rows.length === 0 ? ( + ) : shouldVirtualize ? ( + <> + {paddingTop > 0 && ( + + + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + return renderRow(row); + })} + {paddingBottom > 0 && ( + + + )} + ) : ( - table.getRowModel().rows.map((row) => ( - onRowClick(row.original) : undefined} - tabIndex={onRowClick ? 0 : undefined} - onKeyDown={onRowClick ? (e) => { - if (e.key === 'Enter') onRowClick(row.original); - } : undefined} - > - {row.getVisibleCells().map((cell) => ( - - ))} - - )) + rows.map((row) => renderRow(row)) )}
    {emptyMessage || t('table.empty')}
    +
    +
    - {flexRender(cell.column.columnDef.cell, cell.getContext())} -
    diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 4fc0373..5ab8d34 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,35 +1,52 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { AppShell } from '@/components/layout/AppShell'; import { ProtectedRoute } from './ProtectedRoute'; import { LoginPage } from '@/pages/Login'; import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest'; import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm'; -import { DashboardPage } from '@/pages/Dashboard'; -import { ContactsListPage } from '@/pages/ContactsList'; -import { AuditLogPage } from '@/pages/AuditLog'; -import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults'; -import { SettingsPage } from '@/pages/Settings'; -import { SettingsProfilePage } from '@/pages/SettingsProfile'; -import { SettingsRolesPage } from '@/pages/SettingsRoles'; -import { SettingsUsersPage } from '@/pages/SettingsUsers'; -import { SettingsGroupsPage } from '@/pages/SettingsGroups'; -import { SettingsPluginsPage } from '@/pages/SettingsPlugins'; -import { SettingsSystemPage } from '@/pages/SettingsSystem'; -import { SettingsCurrenciesPage } from '@/pages/SettingsCurrencies'; -import { SettingsTaxesPage } from '@/pages/SettingsTaxes'; -import { SettingsSequencesPage } from '@/pages/SettingsSequences'; -import { CalendarPage } from '@/pages/Calendar'; -import { CalendarKanbanPage } from '@/pages/CalendarKanban'; -import { DmsPage } from '@/pages/Dms'; -import { DmsTrashPage } from '@/pages/DmsTrash'; -import { MailPage } from '@/pages/Mail'; -import { MailSettingsPage } from '@/pages/MailSettings'; -import { SettingsNotificationsPage } from '@/pages/SettingsNotifications'; -import { AIAssistantPage } from '@/pages/AIAssistant'; -import { AISettingsPage } from '@/pages/AISettings'; -import { ProactiveAISettings } from '@/pages/ProactiveAISettings'; -import { SettingsThemePage } from '@/pages/SettingsTheme'; +import { Loader2 } from 'lucide-react'; + +// Lazy-loaded pages (code-splitting) +const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage }))); +const ContactsListPage = React.lazy(() => import('@/pages/ContactsList').then(m => ({ default: m.ContactsListPage }))); +const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage }))); +const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage }))); +const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage }))); +const SettingsProfilePage = React.lazy(() => import('@/pages/SettingsProfile').then(m => ({ default: m.SettingsProfilePage }))); +const SettingsRolesPage = React.lazy(() => import('@/pages/SettingsRoles').then(m => ({ default: m.SettingsRolesPage }))); +const SettingsUsersPage = React.lazy(() => import('@/pages/SettingsUsers').then(m => ({ default: m.SettingsUsersPage }))); +const SettingsGroupsPage = React.lazy(() => import('@/pages/SettingsGroups').then(m => ({ default: m.SettingsGroupsPage }))); +const SettingsPluginsPage = React.lazy(() => import('@/pages/SettingsPlugins').then(m => ({ default: m.SettingsPluginsPage }))); +const SettingsSystemPage = React.lazy(() => import('@/pages/SettingsSystem').then(m => ({ default: m.SettingsSystemPage }))); +const SettingsCurrenciesPage = React.lazy(() => import('@/pages/SettingsCurrencies').then(m => ({ default: m.SettingsCurrenciesPage }))); +const SettingsTaxesPage = React.lazy(() => import('@/pages/SettingsTaxes').then(m => ({ default: m.SettingsTaxesPage }))); +const SettingsSequencesPage = React.lazy(() => import('@/pages/SettingsSequences').then(m => ({ default: m.SettingsSequencesPage }))); +const CalendarPage = React.lazy(() => import('@/pages/Calendar').then(m => ({ default: m.CalendarPage }))); +const CalendarKanbanPage = React.lazy(() => import('@/pages/CalendarKanban').then(m => ({ default: m.CalendarKanbanPage }))); +const DmsPage = React.lazy(() => import('@/pages/Dms').then(m => ({ default: m.DmsPage }))); +const DmsTrashPage = React.lazy(() => import('@/pages/DmsTrash').then(m => ({ default: m.DmsTrashPage }))); +const MailPage = React.lazy(() => import('@/pages/Mail').then(m => ({ default: m.MailPage }))); +const MailSettingsPage = React.lazy(() => import('@/pages/MailSettings').then(m => ({ default: m.MailSettingsPage }))); +const SettingsNotificationsPage = React.lazy(() => import('@/pages/SettingsNotifications').then(m => ({ default: m.SettingsNotificationsPage }))); +const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m => ({ default: m.AIAssistantPage }))); +const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage }))); +const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings }))); +const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage }))); + +/** Centered spinner fallback for lazy-loaded routes */ +function PageLoader() { + return ( +
    +
    + ); +} + +/** Wrap a lazy element in Suspense with PageLoader fallback */ +function withSuspense(element: React.ReactElement) { + return }>{element}; +} const router = createBrowserRouter([ { @@ -51,36 +68,36 @@ const router = createBrowserRouter([ ), children: [ - { path: '/', element: }, - { path: '/dashboard', element: }, - { path: '/contacts', element: }, - { path: '/audit-log', element: }, - { path: '/search', element: }, - { path: '/calendar', element: }, - { path: '/calendar/kanban', element: }, - { path: '/dms', element: }, - { path: '/dms/trash', element: }, - { path: '/mail', element: }, - { path: '/mail/settings', element: }, - { path: '/ai-assistant', element: }, - { path: '/profile', element: }, + { path: '/', element: withSuspense() }, + { path: '/dashboard', element: withSuspense() }, + { path: '/contacts', element: withSuspense() }, + { path: '/audit-log', element: withSuspense() }, + { path: '/search', element: withSuspense() }, + { path: '/calendar', element: withSuspense() }, + { path: '/calendar/kanban', element: withSuspense() }, + { path: '/dms', element: withSuspense() }, + { path: '/dms/trash', element: withSuspense() }, + { path: '/mail', element: withSuspense() }, + { path: '/mail/settings', element: withSuspense() }, + { path: '/ai-assistant', element: withSuspense() }, + { path: '/profile', element: withSuspense() }, { path: '/settings', - element: , + element: withSuspense(), children: [ - { path: 'roles', element: }, - { path: 'users', element: }, - { path: 'groups', element: }, - { path: 'plugins', element: }, - { path: 'system', element: }, - { path: 'currencies', element: }, - { path: 'taxes', element: }, - { path: 'sequences', element: }, - { path: 'mail', element: }, - { path: 'notifications', element: }, - { path: 'ai', element: }, - { path: 'ai-proactive', element: }, - { path: 'theme', element: }, + { path: 'roles', element: withSuspense() }, + { path: 'users', element: withSuspense() }, + { path: 'groups', element: withSuspense() }, + { path: 'plugins', element: withSuspense() }, + { path: 'system', element: withSuspense() }, + { path: 'currencies', element: withSuspense() }, + { path: 'taxes', element: withSuspense() }, + { path: 'sequences', element: withSuspense() }, + { path: 'mail', element: withSuspense() }, + { path: 'notifications', element: withSuspense() }, + { path: 'ai', element: withSuspense() }, + { path: 'ai-proactive', element: withSuspense() }, + { path: 'theme', element: withSuspense() }, ], }, ], diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7e64b90..b531593 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,22 @@ export default defineConfig({ }, }, }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules')) { + if (id.includes('react-router')) return 'react-vendor'; + if (id.includes('react-dom')) return 'react-vendor'; + if (id.includes('/react/') || id.includes('\\react\\')) return 'react-vendor'; + if (id.includes('@tanstack')) return 'tanstack'; + if (id.includes('lucide-react') || id.includes('date-fns') || id.includes('clsx')) return 'ui'; + if (id.includes('i18next') || id.includes('react-i18next')) return 'i18n'; + } + }, + }, + }, + }, test: { globals: true, environment: 'jsdom',