Compare commits
	
		
			70 Commits
		
	
	
		
			31600e5f1b
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | f7adaf1b18 | ||
|   | b3209a4019 | ||
|   | efdb48919f | ||
|   | 6b8d5acc0d | ||
|   | 01a19b9144 | ||
|   | 74d6a8b269 | ||
|   | c33de6ada5 | ||
|   | 15107a48bd | ||
|   | 73699009fc | ||
|   | 55dc96085d | ||
|   | 9cdb76273d | ||
|   | 49dead566c | ||
|   | 2fa6b95012 | ||
|   | f5acde78de | ||
|   | d9bfaba977 | ||
|   | f42d08c091 | ||
|   | aa62b06c23 | ||
|   | d699af9d75 | ||
|   | fead820091 | ||
|   | c69252cc1a | ||
|   | 2cb7264450 | ||
|   | f51382164d | ||
|   | e8e2ed4ff1 | ||
|   | 2184183071 | ||
| 7e88f9ac4b | |||
|   | cb27e16a10 | ||
|   | 16d95b11f8 | ||
|   | 304d5a6e59 | ||
| 24f82889e1 | |||
| 2dc6cf9fcb | |||
| 0a74c7a22a | |||
| af323aade7 | |||
|   | ec2d7d6637 | ||
|   | b2488ba7d9 | ||
|   | e55d9a8cf4 | ||
|   | b79d976c3e | ||
|   | 38626a3a81 | ||
|   | 347e61a029 | ||
|   | bec10610e1 | ||
|   | 55cb3fb34f | ||
|   | 1f11c47484 | ||
|   | 2116e134a9 | ||
|   | 2eeb0b42d8 | ||
|   | 3115d45135 | ||
|   | 339bad77ac | ||
|   | d8c890313f | ||
|   | 91ed5ccaa5 | ||
|   | e85a401209 | ||
|   | d59632e1f6 | ||
|   | 4fbc9e40f8 | ||
|   | 4368723f3f | ||
|   | 6b493265b2 | ||
|   | cfdad88682 | ||
|   | 86dd772e9d | ||
|   | 0d329784c5 | ||
|   | e4c8ed534d | ||
|   | 6300421693 | ||
|   | 4ac86a9097 | ||
|   | 7714a6c704 | ||
|   | 4eb3fa590c | ||
|   | ff2dd6b095 | ||
|   | c3a3ea1d84 | ||
|   | 7a6ac15aad | ||
|   | afdfeee79a | ||
|   | 17708bb8ba | ||
|   | 7757334c7b | ||
|   | eb49416000 | ||
|   | 2849ee2e6b | ||
|   | c9de10c46e | ||
|   | 03022206ae | 
| @@ -3,13 +3,13 @@ | ||||
|  | ||||
| <head> | ||||
|   <meta charset="UTF-8" /> | ||||
|   <link rel="icon" type="image/png" href="/favicon.png" /> | ||||
|   <link rel="icon" type="image/png" href="/MiniLogo.png" /> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|  | ||||
|   <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||
|   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||
|   <link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> | ||||
|   <title>Fendi</title> | ||||
|   <title>Dream Views</title> | ||||
|  | ||||
|   <meta name="viewport" content="initial-scale=1, width=device-width" /> | ||||
| </head> | ||||
|   | ||||
							
								
								
									
										463
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -13,9 +13,14 @@ | ||||
|         "@fontsource/roboto": "^5.2.5", | ||||
|         "@mui/icons-material": "^7.1.0", | ||||
|         "@mui/material": "^7.1.0", | ||||
|         "@mui/x-charts": "^8.10.2", | ||||
|         "@mui/x-data-grid": "^8.5.0", | ||||
|         "@react-oauth/google": "^0.12.2", | ||||
|         "jwt-decode": "^4.0.0", | ||||
|         "notistack": "^3.0.2", | ||||
|         "react": "^19.1.0", | ||||
|         "react-dom": "^19.1.0" | ||||
|         "react-dom": "^19.1.0", | ||||
|         "react-router-dom": "^7.7.1" | ||||
|       }, | ||||
|       "devDependencies": { | ||||
|         "@eslint/js": "^9.25.0", | ||||
| @@ -262,9 +267,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@babel/runtime": { | ||||
|       "version": "7.27.1", | ||||
|       "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", | ||||
|       "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", | ||||
|       "version": "7.28.3", | ||||
|       "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", | ||||
|       "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=6.9.0" | ||||
| @@ -1384,12 +1389,12 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/types": { | ||||
|       "version": "7.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.2.tgz", | ||||
|       "integrity": "sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==", | ||||
|       "version": "7.4.5", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.5.tgz", | ||||
|       "integrity": "sha512-ZPwlAOE3e8C0piCKbaabwrqZbW4QvWz0uapVPWya7fYj6PeDkl5sSJmomT7wjOcZGPB48G/a6Ubidqreptxz4g==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1" | ||||
|         "@babel/runtime": "^7.28.2" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
| @@ -1401,17 +1406,17 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/utils": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", | ||||
|       "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", | ||||
|       "version": "7.3.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.1.tgz", | ||||
|       "integrity": "sha512-/31y4wZqVWa0jzMnzo6JPjxwP6xXy4P3+iLbosFg/mJQowL1KIou0LC+lquWW60FKVbKz5ZUWBg2H3jausa0pw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@types/prop-types": "^15.7.14", | ||||
|         "@babel/runtime": "^7.28.2", | ||||
|         "@mui/types": "^7.4.5", | ||||
|         "@types/prop-types": "^15.7.15", | ||||
|         "clsx": "^2.1.1", | ||||
|         "prop-types": "^15.8.1", | ||||
|         "react-is": "^19.1.0" | ||||
|         "react-is": "^19.1.1" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
| @@ -1430,6 +1435,90 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-charts": { | ||||
|       "version": "8.10.2", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.10.2.tgz", | ||||
|       "integrity": "sha512-F4SdpixbaAeNaZHylYLpaj/WD1jKn6gMD1Twbd+Y8FzZneE2mrtXwaQc6W2Hbri/VdJl2OV55nL5pFMpDSlwAA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.28.2", | ||||
|         "@mui/utils": "^7.3.1", | ||||
|         "@mui/x-charts-vendor": "8.6.0", | ||||
|         "@mui/x-internal-gestures": "0.2.4", | ||||
|         "@mui/x-internals": "8.10.2", | ||||
|         "bezier-easing": "^2.1.0", | ||||
|         "clsx": "^2.1.1", | ||||
|         "prop-types": "^15.8.1", | ||||
|         "reselect": "^5.1.1", | ||||
|         "use-sync-external-store": "^1.5.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@emotion/react": "^11.9.0", | ||||
|         "@emotion/styled": "^11.8.1", | ||||
|         "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", | ||||
|         "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", | ||||
|         "react": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@emotion/react": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "@emotion/styled": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-charts-vendor": { | ||||
|       "version": "8.6.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.6.0.tgz", | ||||
|       "integrity": "sha512-TTtfhxXuwtoZfyno7+4y3ZhZeFqavFJecWbteLEby0lFqALWB9GGJpkc1TIHWr3GkWE5UHEbdADZ0pfrPenezA==", | ||||
|       "license": "MIT AND ISC", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.6", | ||||
|         "@types/d3-color": "^3.1.3", | ||||
|         "@types/d3-delaunay": "^6.0.4", | ||||
|         "@types/d3-interpolate": "^3.0.4", | ||||
|         "@types/d3-scale": "^4.0.9", | ||||
|         "@types/d3-shape": "^3.1.7", | ||||
|         "@types/d3-time": "^3.0.4", | ||||
|         "@types/d3-timer": "^3.0.2", | ||||
|         "d3-color": "^3.1.0", | ||||
|         "d3-delaunay": "^6.0.4", | ||||
|         "d3-interpolate": "^3.0.1", | ||||
|         "d3-scale": "^4.0.2", | ||||
|         "d3-shape": "^3.2.0", | ||||
|         "d3-time": "^3.1.0", | ||||
|         "d3-timer": "^3.0.1", | ||||
|         "delaunator": "^5.0.1", | ||||
|         "robust-predicates": "^3.0.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-charts/node_modules/@mui/x-internals": { | ||||
|       "version": "8.10.2", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.10.2.tgz", | ||||
|       "integrity": "sha512-dlC0BQRRBdiWtqn1yDppaHYRUjU3OuPWTxy0UtqxDaJjJf4pfR8ALr243nbxgJAFqvQyWPWyO4A6p9x9eJMJEQ==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.28.2", | ||||
|         "@mui/utils": "^7.3.1", | ||||
|         "reselect": "^5.1.1", | ||||
|         "use-sync-external-store": "^1.5.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/mui-org" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-data-grid": { | ||||
|       "version": "8.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.5.0.tgz", | ||||
| @@ -1468,6 +1557,15 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-internal-gestures": { | ||||
|       "version": "0.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.2.4.tgz", | ||||
|       "integrity": "sha512-Hpc5+LQfT0TrI7O0ngBlZ1LD6Gp1h5DUNs4ABbrotY/2OsOfJOzltV/QtBjINTmiBzDPnrE8l8nEiG97qOWX3Q==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.28.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-internals": { | ||||
|       "version": "8.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.5.0.tgz", | ||||
| @@ -1499,6 +1597,16 @@ | ||||
|         "url": "https://opencollective.com/popperjs" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@react-oauth/google": { | ||||
|       "version": "0.12.2", | ||||
|       "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz", | ||||
|       "integrity": "sha512-d1GVm2uD4E44EJft2RbKtp8Z1fp/gK8Lb6KHgs3pHlM0PxCXGLaq8LLYQYENnN4xPWO1gkL4apBtlPKzpLvZwg==", | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
|         "react": ">=16.8.0", | ||||
|         "react-dom": ">=16.8.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@rollup/rollup-android-arm-eabi": { | ||||
|       "version": "4.40.2", | ||||
|       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", | ||||
| @@ -1824,6 +1932,63 @@ | ||||
|         "@babel/types": "^7.20.7" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/d3-color": { | ||||
|       "version": "3.1.3", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", | ||||
|       "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/d3-delaunay": { | ||||
|       "version": "6.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", | ||||
|       "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/d3-interpolate": { | ||||
|       "version": "3.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", | ||||
|       "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@types/d3-color": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/d3-path": { | ||||
|       "version": "3.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", | ||||
|       "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/d3-scale": { | ||||
|       "version": "4.0.9", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", | ||||
|       "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@types/d3-time": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/d3-shape": { | ||||
|       "version": "3.1.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", | ||||
|       "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@types/d3-path": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/d3-time": { | ||||
|       "version": "3.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", | ||||
|       "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/d3-timer": { | ||||
|       "version": "3.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", | ||||
|       "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/estree": { | ||||
|       "version": "1.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", | ||||
| @@ -1845,9 +2010,9 @@ | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/prop-types": { | ||||
|       "version": "15.7.14", | ||||
|       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", | ||||
|       "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", | ||||
|       "version": "15.7.15", | ||||
|       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", | ||||
|       "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/react": { | ||||
| @@ -1997,6 +2162,12 @@ | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/bezier-easing": { | ||||
|       "version": "2.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", | ||||
|       "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/body-parser": { | ||||
|       "version": "2.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", | ||||
| @@ -2296,6 +2467,130 @@ | ||||
|       "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/d3-array": { | ||||
|       "version": "3.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", | ||||
|       "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "internmap": "1 - 2" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-color": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", | ||||
|       "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", | ||||
|       "license": "ISC", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-delaunay": { | ||||
|       "version": "6.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", | ||||
|       "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "delaunator": "5" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-format": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", | ||||
|       "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", | ||||
|       "license": "ISC", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-interpolate": { | ||||
|       "version": "3.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", | ||||
|       "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "d3-color": "1 - 3" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-path": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", | ||||
|       "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", | ||||
|       "license": "ISC", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-scale": { | ||||
|       "version": "4.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", | ||||
|       "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "d3-array": "2.10.0 - 3", | ||||
|         "d3-format": "1 - 3", | ||||
|         "d3-interpolate": "1.2.0 - 3", | ||||
|         "d3-time": "2.1.1 - 3", | ||||
|         "d3-time-format": "2 - 4" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-shape": { | ||||
|       "version": "3.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", | ||||
|       "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "d3-path": "^3.1.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-time": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", | ||||
|       "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "d3-array": "2 - 3" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-time-format": { | ||||
|       "version": "4.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", | ||||
|       "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "d3-time": "1 - 3" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/d3-timer": { | ||||
|       "version": "3.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", | ||||
|       "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", | ||||
|       "license": "ISC", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/debug": { | ||||
|       "version": "4.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", | ||||
| @@ -2320,6 +2615,15 @@ | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/delaunator": { | ||||
|       "version": "5.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", | ||||
|       "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "robust-predicates": "^3.0.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/depd": { | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", | ||||
| @@ -2993,6 +3297,15 @@ | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/goober": { | ||||
|       "version": "2.1.16", | ||||
|       "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", | ||||
|       "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
|         "csstype": "^3.0.10" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/gopd": { | ||||
|       "version": "1.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", | ||||
| @@ -3129,6 +3442,15 @@ | ||||
|       "dev": true, | ||||
|       "license": "ISC" | ||||
|     }, | ||||
|     "node_modules/internmap": { | ||||
|       "version": "2.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", | ||||
|       "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", | ||||
|       "license": "ISC", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/ipaddr.js": { | ||||
|       "version": "1.9.1", | ||||
|       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", | ||||
| @@ -3268,6 +3590,15 @@ | ||||
|         "node": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jwt-decode": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", | ||||
|       "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=18" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/keyv": { | ||||
|       "version": "4.5.4", | ||||
|       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", | ||||
| @@ -3461,6 +3792,37 @@ | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/notistack": { | ||||
|       "version": "3.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.2.tgz", | ||||
|       "integrity": "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "clsx": "^1.1.0", | ||||
|         "goober": "^2.0.33" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12.0.0", | ||||
|         "npm": ">=6.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/notistack" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/notistack/node_modules/clsx": { | ||||
|       "version": "1.2.1", | ||||
|       "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", | ||||
|       "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/object-assign": { | ||||
|       "version": "4.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", | ||||
| @@ -3814,9 +4176,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-is": { | ||||
|       "version": "19.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", | ||||
|       "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", | ||||
|       "version": "19.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", | ||||
|       "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/react-refresh": { | ||||
| @@ -3829,6 +4191,53 @@ | ||||
|         "node": ">=0.10.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-router": { | ||||
|       "version": "7.7.1", | ||||
|       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz", | ||||
|       "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==", | ||||
|       "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.7.1", | ||||
|       "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.1.tgz", | ||||
|       "integrity": "sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "react-router": "7.7.1" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=20.0.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": ">=18", | ||||
|         "react-dom": ">=18" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-router/node_modules/cookie": { | ||||
|       "version": "1.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", | ||||
|       "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=18" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-transition-group": { | ||||
|       "version": "4.4.5", | ||||
|       "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", | ||||
| @@ -3880,6 +4289,12 @@ | ||||
|         "node": ">=4" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/robust-predicates": { | ||||
|       "version": "3.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", | ||||
|       "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", | ||||
|       "license": "Unlicense" | ||||
|     }, | ||||
|     "node_modules/rollup": { | ||||
|       "version": "4.40.2", | ||||
|       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", | ||||
| @@ -4020,6 +4435,12 @@ | ||||
|         "node": ">= 18" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/set-cookie-parser": { | ||||
|       "version": "2.7.1", | ||||
|       "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", | ||||
|       "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/setprototypeof": { | ||||
|       "version": "1.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", | ||||
|   | ||||
| @@ -15,9 +15,14 @@ | ||||
|     "@fontsource/roboto": "^5.2.5", | ||||
|     "@mui/icons-material": "^7.1.0", | ||||
|     "@mui/material": "^7.1.0", | ||||
|     "@mui/x-charts": "^8.10.2", | ||||
|     "@mui/x-data-grid": "^8.5.0", | ||||
|     "@react-oauth/google": "^0.12.2", | ||||
|     "jwt-decode": "^4.0.0", | ||||
|     "notistack": "^3.0.2", | ||||
|     "react": "^19.1.0", | ||||
|     "react-dom": "^19.1.0" | ||||
|     "react-dom": "^19.1.0", | ||||
|     "react-router-dom": "^7.7.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.25.0", | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/AmbientDesign.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 874 B | 
							
								
								
									
										
											BIN
										
									
								
								public/Catalog.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 507 B | 
							
								
								
									
										
											BIN
										
									
								
								public/Contract.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 314 B | 
							
								
								
									
										
											BIN
										
									
								
								public/Dashboard.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 270 B | 
							
								
								
									
										
											BIN
										
									
								
								public/DefineYourStyle.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 649 B | 
							
								
								
									
										
											BIN
										
									
								
								public/Expand.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 290 B | 
							
								
								
									
										
											BIN
										
									
								
								public/ExportAndSharing.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 406 B | 
							
								
								
									
										
											BIN
										
									
								
								public/FlatLayouts.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 571 B | 
							
								
								
									
										
											BIN
										
									
								
								public/Help.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 695 B | 
							
								
								
									
										
											BIN
										
									
								
								public/Logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/MiniLogo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/Settings.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 921 B | 
							
								
								
									
										
											BIN
										
									
								
								public/ShoppingCart.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 498 B | 
							
								
								
									
										
											BIN
										
									
								
								public/alert.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 539 B | 
							
								
								
									
										
											BIN
										
									
								
								public/logo.png
									
									
									
									
									
								
							
							
						
						| Before Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/refresh.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 553 B | 
| @@ -1,5 +1,5 @@ | ||||
| #root { | ||||
|   max-width: 1280px; | ||||
|  | ||||
|   margin: 0 auto; | ||||
|   padding: 2rem; | ||||
|   text-align: center; | ||||
|   | ||||
							
								
								
									
										99
									
								
								src/App.jsx
									
									
									
									
									
								
							
							
						
						| @@ -1,45 +1,84 @@ | ||||
| import { useState } from 'react' | ||||
| import './App.css' | ||||
| import { useState } from 'react'; | ||||
| import { Box, useMediaQuery } from '@mui/material'; | ||||
| import { useTheme } from '@mui/material/styles'; | ||||
| import AppHeader from './components/AppHeader'; | ||||
| import MenuDrawerPrivate, { OPEN_WIDTH, MINI_WIDTH } from './components/MenuDrawerPrivate'; | ||||
| import Footer from './components/Footer'; | ||||
| import Box from '@mui/material/Box'; | ||||
| import Dashboard from './private/dashboard/Dashboard'; | ||||
| import UserManagement from './private/users/UserManagement'; | ||||
| import ProductCollections from './private/catalogs/products/ProductCollections'; | ||||
| import Categories from './private/catalogs/categories/Categories'; | ||||
| import LoginPage from './private/LoginPage'; | ||||
| import { Routes, Route, Navigate } from 'react-router-dom'; | ||||
| import { useAuth } from './context/AuthContext'; | ||||
|  | ||||
| import Products from './private/products/Products'; | ||||
| import Clients from './private/clients/Clients'; | ||||
| import Providers from './private/providers/Providers'; | ||||
| import Categories from './private/categories/Categories'; | ||||
| const DRAWER_EXPANDED = OPEN_WIDTH; | ||||
| const DRAWER_COLLAPSED = MINI_WIDTH; | ||||
| const APPBAR_HEIGHT = 64; | ||||
|  | ||||
| function App() { | ||||
|   const [zone, setZone] = useState('public'); // Could be 'public' | 'restricted' | 'private' | ||||
|   const [currentView, setCurrentView] = useState('Products'); | ||||
| export default function App() { | ||||
|   const theme = useTheme(); | ||||
|   const isMobile = useMediaQuery('(max-width:900px)'); | ||||
|   const [drawerExpanded, setDrawerExpanded] = useState(true); | ||||
|   const [currentView, setCurrentView] = useState('Dashboard'); | ||||
|   const { user, initializing } = useAuth(); | ||||
|  | ||||
|   const mainLeft = isMobile ? 0 : (drawerExpanded ? DRAWER_EXPANDED : DRAWER_COLLAPSED); | ||||
|  | ||||
|   if (initializing) return null; | ||||
|  | ||||
|   if (!user) { | ||||
|     return ( | ||||
|       <Routes> | ||||
|         <Route path="/login" element={<LoginPage />} /> | ||||
|         <Route path="*" element={<Navigate to="/login" replace />} /> | ||||
|       </Routes> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <AppHeader zone="private" currentPage={currentView} leftOffset={mainLeft} /> | ||||
|  | ||||
|       <MenuDrawerPrivate | ||||
|         onSelect={(value) => setCurrentView(value)} | ||||
|         onExpandedChange={(expanded) => setDrawerExpanded(expanded)} | ||||
|       /> | ||||
|  | ||||
|       <Box | ||||
|         component="main" | ||||
|         sx={{ | ||||
|           display: 'flex', | ||||
|           flexDirection: 'column', | ||||
|           minHeight: '100vh', // full height of the viewport | ||||
|           ml: { xs: 0, md: `${mainLeft}px` }, | ||||
|           mt: `${APPBAR_HEIGHT}px`, | ||||
|           p: 2, | ||||
|           transition: theme.transitions.create('margin-left', { | ||||
|             duration: theme.transitions.duration.standard, | ||||
|             easing: theme.transitions.easing.sharp, | ||||
|           }), | ||||
|         }} | ||||
|       > | ||||
|  | ||||
|         <AppHeader zone={zone} onSelectMenuItem={(view) => setCurrentView(view)} /> | ||||
|  | ||||
|         {/* Main content area */} | ||||
|         <Box component="main" sx={{ flex: 1, p: 2 }}> | ||||
|           {zone === 'private' && <Clients />} | ||||
|           {zone === 'restricted' && <Clients />} | ||||
|  | ||||
|           {zone === 'public' && currentView === 'Products' && <Products />} | ||||
|           {zone === 'public' && currentView === 'Clients' && <Clients />} | ||||
|           {zone === 'public' && currentView === 'Providers' && <Providers />} | ||||
|           {zone === 'public' && currentView === 'Categories' && <Categories />} | ||||
|         </Box> | ||||
|         <Footer zone={zone} /> | ||||
|         <Routes> | ||||
|           <Route path="/login" element={<Navigate to="/" replace />} /> | ||||
|           <Route | ||||
|             path="/" | ||||
|             element={ | ||||
|               <> | ||||
|                 {currentView === 'Dashboard' && <Dashboard />} | ||||
|                 {currentView === '/Users/UserManagement' && <UserManagement />} | ||||
|                 {currentView === '/Products Management/Catalog Management/Product Collections' && ( | ||||
|                   <ProductCollections /> | ||||
|                 )} | ||||
|                 {currentView === '/Products Management/Catalog Management/Categories' && ( | ||||
|                   <Categories /> | ||||
|                 )} | ||||
|               </> | ||||
|             } | ||||
|           /> | ||||
|         </Routes> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ height: 64 }} /> | ||||
|       <Footer zone="private" /> | ||||
|     </> | ||||
|   ) | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default App | ||||
|   | ||||
							
								
								
									
										116
									
								
								src/api/CategoriesApi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,116 @@ | ||||
| // src/api/CategoriesApi.js | ||||
| export default class CategoriesApi { | ||||
|   constructor(token) { | ||||
|     // IMPORTANTE: singular "Tag", no "Tags" | ||||
|     this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/Tag'; | ||||
|     this.token = token; | ||||
|   } | ||||
|  | ||||
|   headers(json = true) { | ||||
|     return { | ||||
|       accept: 'application/json', | ||||
|       ...(json ? { 'Content-Type': 'application/json' } : {}), | ||||
|       ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // Utilidad: validar ObjectId (24-hex) — el DAL lo exige para ChangeStatus | ||||
|   static isHex24(v) { | ||||
|     return typeof v === 'string' && /^[0-9a-fA-F]{24}$/.test(v); | ||||
|   } | ||||
|  | ||||
|   // (Opcional) Utilidad: validar campos mínimos en create/update para evitar 400 | ||||
|   static ensureFields(obj, fields) { | ||||
|     const missing = fields.filter((k) => obj[k] === undefined || obj[k] === null || obj[k] === ''); | ||||
|     if (missing.length) { | ||||
|       throw new Error(`Missing required field(s): ${missing.join(', ')}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // GET /Tag/GetAll | ||||
|   async getAll() { | ||||
|     const res = await fetch(`${this.baseUrl}/GetAll`, { | ||||
|       method: 'GET', | ||||
|       headers: this.headers(false), | ||||
|     }); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(`GetAll error ${res.status}: ${await res.text()}`); | ||||
|     } | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   // POST /Tag/Create | ||||
|   // payload esperado (min): tenantId, tagName, typeId(_id TagType), slug, displayOrder, icon, parentTagId([]) | ||||
|   async create(payload) { | ||||
|     // Validaciones básicas para evitar 400 comunes | ||||
|     CategoriesApi.ensureFields(payload, ['tenantId', 'tagName', 'typeId', 'icon']); | ||||
|     if (!Array.isArray(payload.parentTagId)) { | ||||
|       payload.parentTagId = []; | ||||
|     } | ||||
|  | ||||
|     const res = await fetch(`${this.baseUrl}/Create`, { | ||||
|       method: 'POST', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(`Create error ${res.status}: ${await res.text()}`); | ||||
|     } | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   // PUT /Tag/Update | ||||
|   // payload esperado (min): id(GUID) ó _id(24-hex) según backend, + tenantId, tagName, typeId, icon, etc. | ||||
|   async update(payload) { | ||||
|     CategoriesApi.ensureFields(payload, ['tenantId', 'tagName', 'typeId', 'icon']); | ||||
|     const res = await fetch(`${this.baseUrl}/Update`, { | ||||
|       method: 'PUT', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(`Update error ${res.status}: ${await res.text()}`); | ||||
|     } | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   // PATCH /Tag/ChangeStatus | ||||
|   // Debe mandarse { id: <_id 24-hex>, status: 'Active'|'Inactive' } | ||||
|   async changeStatus({ id, status }) { | ||||
|     if (!CategoriesApi.isHex24(id)) { | ||||
|       // Evitar el 500 "String should contain only hexadecimal digits." | ||||
|       throw new Error('ChangeStatus requires a Mongo _id (24-hex) for "id".'); | ||||
|     } | ||||
|     if (!status) { | ||||
|       throw new Error('ChangeStatus requires "status" field.'); | ||||
|     } | ||||
|  | ||||
|     const res = await fetch(`${this.baseUrl}/ChangeStatus`, { | ||||
|       method: 'PATCH', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify({ id, status }), | ||||
|     }); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(`ChangeStatus error ${res.status}: ${await res.text()}`); | ||||
|     } | ||||
|     // Algunos endpoints devuelven vacío; devolvemos parsed o true | ||||
|     try { | ||||
|       return await res.json(); | ||||
|     } catch { | ||||
|       return true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // DELETE /Tag/Delete (si lo usan; muchos usan soft-delete con ChangeStatus/Update) | ||||
|   async delete(payload) { | ||||
|     const res = await fetch(`${this.baseUrl}/Delete`, { | ||||
|       method: 'DELETE', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(`Delete error ${res.status}: ${await res.text()}`); | ||||
|     } | ||||
|     return res.json(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										66
									
								
								src/api/ProductsApi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,66 @@ | ||||
| export default class ProductsApi { | ||||
|   constructor(token) { | ||||
|     this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/FurnitureVariant'; | ||||
|     this.token = token; | ||||
|   } | ||||
|  | ||||
|   headers(json = true) { | ||||
|     return { | ||||
|       accept: 'application/json', | ||||
|       ...(json ? { 'Content-Type': 'application/json' } : {}), | ||||
|       ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async getAllVariants() { | ||||
|     const res = await fetch(`${this.baseUrl}/GetAll`, { | ||||
|       method: 'GET', | ||||
|       headers: this.headers(false), | ||||
|     }); | ||||
|     if (!res.ok) throw new Error(`GetAll error ${res.status}: ${await res.text()}`); | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   // Assuming similar endpoints; adjust names if backend differs. | ||||
|   async createVariant(payload) { | ||||
|     const res = await fetch(`${this.baseUrl}/Create`, { | ||||
|       method: 'POST', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) throw new Error(`Create error ${res.status}: ${await res.text()}`); | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   async updateVariant(payload) { | ||||
|     const res = await fetch(`${this.baseUrl}/Update`, { | ||||
|       method: 'PUT', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) throw new Error(`Update error ${res.status}: ${await res.text()}`); | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   async deleteVariant(payload) { | ||||
|     // If your API is soft-delete via Update status, reuse updateVariant. | ||||
|     const res = await fetch(`${this.baseUrl}/Delete`, { | ||||
|       method: 'DELETE', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) throw new Error(`Delete error ${res.status}: ${await res.text()}`); | ||||
|     return res.json(); | ||||
|   } | ||||
|  | ||||
|   async changeStatusVariant(payload) { | ||||
|     // If your API is change status, reuse updateVariant. | ||||
|     const res = await fetch(`${this.baseUrl}/ChangeStatus`, { | ||||
|       method: 'PATCH', | ||||
|       headers: this.headers(), | ||||
|       body: JSON.stringify(payload), | ||||
|     }); | ||||
|     if (!res.ok) throw new Error(`ChangeStatus error ${res.status}: ${await res.text()}`); | ||||
|     return res.json(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										26
									
								
								src/api/TagTypeApi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,26 @@ | ||||
| // src/api/TagTypeApi.js | ||||
| export default class TagTypeApi { | ||||
|   constructor(token) { | ||||
|     this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/TagType'; | ||||
|     this.token = token; | ||||
|   } | ||||
|  | ||||
|   headers(json = true) { | ||||
|     return { | ||||
|       accept: 'application/json', | ||||
|       ...(json ? { 'Content-Type': 'application/json' } : {}), | ||||
|       ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async getAll() { | ||||
|     const res = await fetch(`${this.baseUrl}/GetAll`, { | ||||
|       method: 'GET', | ||||
|       headers: this.headers(false), | ||||
|     }); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(`TagType.GetAll ${res.status}: ${await res.text()}`); | ||||
|     } | ||||
|     return res.json(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										44
									
								
								src/api/mongo/actions.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| const API_BASE_URL = 'http://portainer.white-enciso.pro:4001/api/v1/MongoSample'; | ||||
|  | ||||
| export async function getExternalData() { | ||||
|   const response = await fetch(`${API_BASE_URL}/GetAll`); | ||||
|   if (!response.ok) throw new Error('Failed to fetch external data'); | ||||
|   const data = await response.json(); | ||||
|   return data; | ||||
| } | ||||
|  | ||||
| export async function createExternalData(data) { | ||||
|   const response = await fetch(`${API_BASE_URL}/Create`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|     }, | ||||
|     body: JSON.stringify(data), | ||||
|   }); | ||||
|   if (!response.ok) throw new Error('Failed to create external data'); | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| export async function updateExternalData(data) { | ||||
|   const response = await fetch(`${API_BASE_URL}/Update`, { | ||||
|     method: 'PUT', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|     }, | ||||
|     body: JSON.stringify(data), | ||||
|   }); | ||||
|   if (!response.ok) throw new Error('Failed to update item'); | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| export async function deleteExternalData(_Id) { | ||||
|   const response = await fetch(`${API_BASE_URL}/Delete`, { | ||||
|     method: 'DELETE', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|     }, | ||||
|     body: JSON.stringify({ _Id }), | ||||
|   }); | ||||
|   if (!response.ok) throw new Error('Failed to delete external data'); | ||||
|   return await response.json(); | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/api/userApi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,70 @@ | ||||
| export default class UserApi { | ||||
|   constructor(token) { | ||||
|     this.baseUrl = 'https://thalos-bff.dream-views.com/api/v1/User'; | ||||
|     this.token = token; | ||||
|   } | ||||
|  | ||||
|   // helper for headers | ||||
|   getHeaders() { | ||||
|     return { | ||||
|       'accept': 'application/json', | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': `Bearer ${this.token}`, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // === GET all users === | ||||
|   async getAllUsers() { | ||||
|     try { | ||||
|       const response = await fetch(`${this.baseUrl}/GetAll`, { | ||||
|         method: 'GET', | ||||
|         headers: this.getHeaders(), | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`Failed to fetch users: ${response.status}`); | ||||
|       } | ||||
|  | ||||
|       return await response.json(); | ||||
|     } catch (err) { | ||||
|       console.error('Error fetching users:', err); | ||||
|       throw err; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // === CREATE a user === | ||||
|   async createUser(userData) { | ||||
|     try { | ||||
|       const response = await fetch(`${this.baseUrl}/Create`, { | ||||
|         method: 'POST', | ||||
|         headers: this.getHeaders(), | ||||
|         body: JSON.stringify(userData), | ||||
|       }); | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`Failed to create user: ${response.status}`); | ||||
|       } | ||||
|       return await response.json(); | ||||
|     } catch (err) { | ||||
|       console.error('Error creating user:', err); | ||||
|       throw err; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // === UPDATE a user === | ||||
|   async updateUser(userData) { | ||||
|     try { | ||||
|       const response = await fetch(`${this.baseUrl}/Update`, { | ||||
|         method: 'PUT', | ||||
|         headers: this.getHeaders(), | ||||
|         body: JSON.stringify(userData), | ||||
|       }); | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`Failed to update user: ${response.status}`); | ||||
|       } | ||||
|       return await response.json(); | ||||
|     } catch (err) { | ||||
|       console.error('Error updating user:', err); | ||||
|       throw err; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										59
									
								
								src/auth/ThalosTokenConnector.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | ||||
| import { useEffect, useState } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| export default function ThalosTokenConnector({ googleIdToken, onSuccess, onError }) { | ||||
|     const [loading, setLoading] = useState(false); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (!googleIdToken) return; | ||||
|  | ||||
|         let cancelled = false; | ||||
|         const run = async () => { | ||||
|             setLoading(true); | ||||
|             try { | ||||
|                 const res = await fetch( | ||||
|                     'https://thalos-bff.dream-views.com/api/v1/Authentication/GenerateToken', | ||||
|                     { | ||||
|                         method: 'GET', | ||||
|                         headers: { | ||||
|                             Accept: 'application/json', | ||||
|                             Authorization: `Bearer ${googleIdToken}`, | ||||
|                         }, | ||||
|                     } | ||||
|                 ); | ||||
|  | ||||
|                 if (!res.ok) { | ||||
|                     const text = await res.text(); | ||||
|                     throw new Error(`Auth exchange failed (${res.status}): ${text || 'No details'}`); | ||||
|                 } | ||||
|  | ||||
|                 const payload = await res.json(); | ||||
|                 if (cancelled) return; | ||||
|  | ||||
|                 if (payload?.token) { | ||||
|                     localStorage.setItem('thalosToken', payload.token); | ||||
|                 } | ||||
|  | ||||
|                 onSuccess?.(payload); | ||||
|             } catch (err) { | ||||
|                 if (!cancelled) onError?.(err); | ||||
|                 console.error('Thalos token exchange error:', err); | ||||
|             } finally { | ||||
|                 if (!cancelled) setLoading(false); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         run(); | ||||
|         return () => { | ||||
|             cancelled = true; | ||||
|         }; | ||||
|     }, [googleIdToken, onSuccess, onError]); | ||||
|  | ||||
|     return null; | ||||
| } | ||||
|  | ||||
| ThalosTokenConnector.propTypes = { | ||||
|     googleIdToken: PropTypes.string.isRequired, | ||||
|     onSuccess: PropTypes.func, | ||||
|     onError: PropTypes.func, | ||||
| }; | ||||
| @@ -1,75 +1,72 @@ | ||||
| import { useState } from 'react'; | ||||
| import fendiLogo from '/favicon.png' | ||||
| import { AppBar, Toolbar, Typography, InputBase, IconButton, Box } from '@mui/material'; | ||||
| import SearchIcon from '@mui/icons-material/Search'; | ||||
| import MenuDrawer from './MenuDrawer'; | ||||
| import MenuIcon from '@mui/icons-material/Menu'; | ||||
| import { useAuth } from '../context/AuthContext'; | ||||
| import { AppBar, Toolbar, Typography, IconButton, Box, Avatar, useMediaQuery } from '@mui/material'; | ||||
| import { useTheme } from '@mui/material/styles'; | ||||
| import { OPEN_WIDTH, MINI_WIDTH } from './MenuDrawerPrivate'; | ||||
|  | ||||
| export default function AppHeader({ zone = 'public', onSelectMenuItem }) { | ||||
|  | ||||
|   const bgColor = { | ||||
|     public: '#40120EFF', | ||||
|     restricted: '#40120EFF', | ||||
|     private: '#40120EFF', | ||||
|   }; | ||||
| export default function AppHeader({ drawerExpanded = true, currentPage = 'Dashboard' }) { | ||||
|   const theme = useTheme(); | ||||
|   const isMobile = useMediaQuery('(max-width:900px)'); | ||||
|   const { user } = useAuth(); | ||||
|  | ||||
|   const [menuOpen, setMenuOpen] = useState(false); | ||||
|  | ||||
|   const isPrivate = zone === 'private'; | ||||
|   const isRestricted = zone === 'restricted'; | ||||
|   const isPublic = zone === 'public'; | ||||
|   const leftOffset = isMobile ? 0 : (drawerExpanded ? OPEN_WIDTH : MINI_WIDTH); | ||||
|  | ||||
|   return ( | ||||
|     <AppBar position="static" | ||||
|     <AppBar | ||||
|       position="fixed" | ||||
|       sx={{ | ||||
|         textAlign: 'center', | ||||
|         backgroundColor: bgColor[zone], | ||||
|         mt: 'auto', | ||||
|         fontSize: { xs: '0.75rem', md: '1rem' }, | ||||
|       }} > | ||||
|       <Toolbar sx={{ justifyContent: 'space-between', flexWrap: 'wrap' }}> | ||||
|         <Box display="flex" alignItems="center"> | ||||
|           <IconButton edge="start" color="inherit" onClick={() => setMenuOpen(true)}> | ||||
|             <MenuIcon /> | ||||
|           </IconButton> | ||||
|         background: 'white', | ||||
|         color: '#40120E', | ||||
|         boxShadow: '0px 2px 4px rgba(0,0,0,0.1)', | ||||
|       }} | ||||
|     > | ||||
|       <Toolbar sx={{ minHeight: 64 }}> | ||||
|         <Box | ||||
|           sx={{ | ||||
|             ml: `${leftOffset}px`, | ||||
|             transition: theme.transitions.create('margin-left', { | ||||
|               duration: theme.transitions.duration.standard, | ||||
|               easing: theme.transitions.easing.sharp, | ||||
|             }), | ||||
|             display: 'flex', | ||||
|             alignItems: 'center', | ||||
|             flexGrow: 1, | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="h6" sx={{ fontWeight: 600 }}> | ||||
|             {currentPage} | ||||
|           </Typography> | ||||
|         </Box> | ||||
|  | ||||
|         {/* Search only visible for restricted or private zones */} | ||||
|         {(isRestricted || isPrivate || isPublic) && ( | ||||
|           <Box sx={{ position: 'relative', display: { xs: 'none', md: 'flex' } }}> | ||||
|             <SearchIcon sx={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#A68A72FF' }} /> | ||||
|             <InputBase | ||||
|               placeholder="Search…" | ||||
|               sx={{ | ||||
|                 pl: 4, | ||||
|                 pr: 2, | ||||
|                 py: 0.5, | ||||
|                 borderRadius: 1, | ||||
|                 color: '#A68A72FF', | ||||
|                 width: { md: '300px', lg: '400px' } | ||||
|               }} | ||||
|             /> | ||||
|           </Box> | ||||
|         )} | ||||
|                 <Box | ||||
|           sx={{ | ||||
|             position: 'absolute', | ||||
|             right: 20, | ||||
|             top: '50%', | ||||
|             transform: 'translateY(-50%)', | ||||
|             display: 'flex', | ||||
|             alignItems: 'center', | ||||
|             gap: '10px', | ||||
|  | ||||
|         {/* Login button only visible for public zone */} | ||||
|         {isPublic && ( | ||||
|           <Box> | ||||
|             <IconButton color="inherit"> | ||||
|               <Typography variant="button" color="#A68A72FF"> | ||||
|                 Login | ||||
|           }} | ||||
|         > | ||||
|           <IconButton> | ||||
|             <img src="/refresh.png" alt="Reload" width={24} height={24} /> | ||||
|           </IconButton> | ||||
|           <IconButton> | ||||
|             <img src="/alert.png" alt="Notifications" width={24} height={24} /> | ||||
|           </IconButton> | ||||
|  | ||||
|           {user && ( | ||||
|             <Box display="flex" alignItems="center" gap={1}> | ||||
|               <Avatar alt={user.name} src={user.picture} /> | ||||
|               <Typography variant="body1" color="#40120EFF"> | ||||
|                 {user.name} | ||||
|               </Typography> | ||||
|             </IconButton> | ||||
|           </Box> | ||||
|         )} | ||||
|  | ||||
|         {/* Rendering the Drawer */} | ||||
|         <MenuDrawer | ||||
|           zone="private" | ||||
|           open={menuOpen} | ||||
|           onClose={() => setMenuOpen(false)} | ||||
|           onSelect={onSelectMenuItem} // pass handler from App | ||||
|         /> | ||||
|             </Box> | ||||
|           )} | ||||
|         </Box> | ||||
|  | ||||
|       </Toolbar> | ||||
|     </AppBar> | ||||
|   | ||||
| @@ -1,32 +1,43 @@ | ||||
| import { Box, Typography } from '@mui/material'; | ||||
| import fendiLogo from '/logo.png' | ||||
| import { AppBar, Toolbar, Typography, Box } from '@mui/material'; | ||||
| import fendiLogo from '/Logo.png'; | ||||
|  | ||||
| export default function Footer({ zone = 'public' }) { | ||||
|     const bgColor = { | ||||
|         public: '#40120EFF', | ||||
|         restricted: '#40120EFF', | ||||
|         private: '#40120EFF', | ||||
|     }; | ||||
|  | ||||
|     const year = new Date().getFullYear(); | ||||
|  | ||||
|     return ( | ||||
|         <Box | ||||
|             component="footer" | ||||
|         <AppBar | ||||
|             position="fixed" | ||||
|             elevation={0} | ||||
|             sx={{ | ||||
|                 p: 2, | ||||
|                 textAlign: 'center', | ||||
|                 backgroundColor: bgColor[zone], | ||||
|                 mt: 'auto', | ||||
|                 fontSize: { xs: '0.75rem', md: '1rem' }, | ||||
|                 top: 'auto', | ||||
|                 bottom: 0, | ||||
|                 backgroundColor: 'white', | ||||
|                 color: '#40120EFF', | ||||
|                 boxShadow: '0px -2px 4px rgba(0,0,0,0.1)', | ||||
|                 border: 'none', | ||||
|                 width: '100%', | ||||
|                 left: 0, | ||||
|                 right: 0, | ||||
|             }} | ||||
|         > | ||||
|             <Typography variant="body2"> | ||||
|                 <img src={fendiLogo} alt="Fendi logo" style={{ height: 10, marginRight: 10 }} /> | ||||
|                 {zone === 'private' | ||||
|                     ? `Admin Panel - Fendi ${year}` | ||||
|                     : `© ${year} Fendi. All rights reserved.`} | ||||
|             </Typography> | ||||
|         </Box> | ||||
|             <Toolbar | ||||
|                 sx={{ | ||||
|                     minHeight: 64, | ||||
|                     px: 2, | ||||
|                     display: 'flex', | ||||
|                     alignItems: 'center', | ||||
|                     justifyContent: 'center', | ||||
|                 }} | ||||
|             > | ||||
|                 <Box display="flex" alignItems="center" gap={1.5}> | ||||
|                     <img src={fendiLogo} alt="Fendi logo" style={{ height: 50 }} /> | ||||
|                     <Typography variant="body2" sx={{ color: '#40120EFF' }}> | ||||
|                         {zone === 'private' | ||||
|                             ? `Admin Panel - Dream Views ${year}` | ||||
|                             : `© ${year} Dream Views. All rights reserved.`} | ||||
|                     </Typography> | ||||
|                 </Box> | ||||
|             </Toolbar> | ||||
|         </AppBar> | ||||
|     ); | ||||
| } | ||||
| @@ -1,74 +1,308 @@ | ||||
| import { Drawer, List, ListItem, ListItemText, ListItemIcon, Avatar, Typography, Box, useMediaQuery } from '@mui/material'; | ||||
| import CategoryIcon from '@mui/icons-material/Category'; | ||||
| import PeopleIcon from '@mui/icons-material/People'; | ||||
| import InventoryIcon from '@mui/icons-material/Inventory'; | ||||
| import LocalShippingIcon from '@mui/icons-material/LocalShipping'; | ||||
| import { | ||||
|   Drawer, | ||||
|   List, | ||||
|   ListItem, | ||||
|   ListItemText, | ||||
|   ListItemIcon, | ||||
|   IconButton, | ||||
|   Box, | ||||
|   useMediaQuery, | ||||
|   InputBase, | ||||
|   Tooltip, | ||||
|   Divider, | ||||
|   ListItemButton, | ||||
|   Collapse | ||||
| } from '@mui/material'; | ||||
| import { useTheme } from '@mui/material/styles'; | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { useAuth } from '../context/AuthContext'; | ||||
|  | ||||
| import ExitToAppIcon from '@mui/icons-material/ExitToApp'; | ||||
| import { useState } from 'react'; | ||||
| import ExpandLess from '@mui/icons-material/ExpandLess'; | ||||
| import ExpandMore from '@mui/icons-material/ExpandMore'; | ||||
|  | ||||
| const menuOptions = { | ||||
|   public: [ | ||||
|     { text: 'Categories', icon: <CategoryIcon /> }, | ||||
|     { text: 'Clients', icon: <PeopleIcon /> }, | ||||
|     { text: 'Products', icon: <InventoryIcon /> }, | ||||
|     { text: 'Providers', icon: <LocalShippingIcon /> }, | ||||
|     { text: 'Dashboard', icon: <img src="/Dashboard.png" alt="Dashboard" width={24} height={24} /> }, | ||||
|     { text: 'Logout', icon: <ExitToAppIcon /> }, | ||||
|   ], | ||||
|   restricted: [], | ||||
|   private: [ | ||||
|     { text: 'Categories', icon: <CategoryIcon /> }, | ||||
|     { text: 'Clients', icon: <PeopleIcon /> }, | ||||
|     { text: 'Products', icon: <InventoryIcon /> }, | ||||
|     { text: 'Providers', icon: <LocalShippingIcon /> }, | ||||
|     { text: 'Dashboard', icon: <img src="/Dashboard.png" alt="Dashboard" width={24} height={24} /> }, | ||||
|     { text: 'Catalog', icon: <img src="/Catalog.png" alt="Catalog" width={24} height={24} /> }, | ||||
|     { text: 'Define your style', icon: <img src="/DefineYourStyle.png" alt="Define your style" width={24} height={24} /> }, | ||||
|     { text: 'Ambient Design', icon: <img src="/AmbientDesign.png" alt="Ambient Design" width={24} height={24} /> }, | ||||
|     { text: 'Flat Layouts and assets', icon: <img src="/FlatLayouts.png" alt="Flat Layouts and assets" width={24} height={24} /> }, | ||||
|     { text: 'Export and sharing', icon: <img src="/ExportAndSharing.png" alt="Export and sharing" width={24} height={24} /> }, | ||||
|     { text: 'Shopping cart', icon: <img src="/ShoppingCart.png" alt="Shopping cart" width={24} height={24} /> }, | ||||
|     { text: 'Settings', icon: <img src="/Settings.png" alt="Settings" width={24} height={24} /> }, | ||||
|     { text: 'Help', icon: <img src="/Help.png" alt="Help" width={24} height={24} /> }, | ||||
|     { text: 'Logout', icon: <ExitToAppIcon /> }, | ||||
|   ], | ||||
| }; | ||||
|  | ||||
| export default function MenuDrawer({ zone = 'public', open, onClose, onSelect }) { | ||||
| const OPEN_WIDTH = 300; | ||||
| const MINI_WIDTH = 72; | ||||
|  | ||||
| export default function MenuDrawer({ zone = 'public', open, onClose, onSelect, onExpandedChange }) { | ||||
|   const theme = useTheme(); | ||||
|   const isMobile = useMediaQuery('(max-width:900px)'); | ||||
|   const items = menuOptions[zone]; | ||||
|   const items = useMemo(() => menuOptions[zone] ?? [], [zone]); | ||||
|   const { logout } = useAuth(); | ||||
|  | ||||
|   const [collapsed, setCollapsed] = useState(false); | ||||
|   const catalogChildren = [ | ||||
|     'Furniture', | ||||
|     'Lighting', | ||||
|     'Textiles', | ||||
|     'Decorative Accessories', | ||||
|     'Kitchen & Dining', | ||||
|     'Outdoor Living', | ||||
|   ]; | ||||
|   const [openCatalog, setOpenCatalog] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const expanded = isMobile ? true : !collapsed; | ||||
|     onExpandedChange?.(expanded); | ||||
|   }, [collapsed, isMobile, onExpandedChange]); | ||||
|  | ||||
|   const paperWidth = isMobile ? OPEN_WIDTH : (collapsed ? MINI_WIDTH : OPEN_WIDTH); | ||||
|  | ||||
|   return ( | ||||
|     <Drawer anchor="left" open={open} onClose={onClose} slotProps={{ | ||||
|       paper: { | ||||
|         sx: { | ||||
|           backgroundColor: '#40120EFF', | ||||
|           width: isMobile ? '100vw' : 250, | ||||
|           color: '#DFCCBCFF' | ||||
|     <Drawer | ||||
|       anchor="left" | ||||
|       variant={isMobile ? 'temporary' : 'permanent'} | ||||
|       open={isMobile ? open : true} | ||||
|       onClose={isMobile ? onClose : undefined} | ||||
|       ModalProps={{ keepMounted: true }} | ||||
|       sx={{ | ||||
|         width: paperWidth, | ||||
|         flexShrink: 0, | ||||
|         '& .MuiDrawer-paper': { | ||||
|           width: paperWidth, | ||||
|           boxSizing: 'border-box', | ||||
|           backgroundColor: '#FFFFFFFF', | ||||
|           color: '#40120EFF', | ||||
|           transition: theme.transitions.create('width', { | ||||
|             duration: theme.transitions.duration.standard, | ||||
|             easing: theme.transitions.easing.sharp, | ||||
|           }), | ||||
|           borderRight: '1px solid rgba(0,0,0,0.08)', | ||||
|         }, | ||||
|       }, | ||||
|     }}> | ||||
|       <Box textAlign="center" p={3}> | ||||
|         <Avatar | ||||
|           src="/favicon.png" | ||||
|           alt="User" | ||||
|           sx={{ width: 64, height: 64, mx: 'auto', mb: 1 }} | ||||
|         /> | ||||
|         <Typography variant="subtitle1" fontWeight={600}>Fendi Casa</Typography> | ||||
|         <Typography variant="body2">Administrator</Typography> | ||||
|       </Box> | ||||
|       <List sx={{ width: isMobile ? '100vw' : 250, marginTop: 2 }}> | ||||
|         {items.map(({ text, icon }, index) => ( | ||||
|           <ListItem key={index} onClick={() => { | ||||
|             onClose(); // Close drawer | ||||
|             onSelect?.(text); // Notify parent of selected item | ||||
|           }}> | ||||
|       }} | ||||
|     > | ||||
|       <Box | ||||
|         sx={{ | ||||
|           display: 'flex', | ||||
|           alignItems: 'center', | ||||
|           gap: 1, | ||||
|           px: collapsed ? 1 : 2, | ||||
|           py: 1.5, | ||||
|           justifyContent: collapsed ? 'center' : 'space-between', | ||||
|         }} | ||||
|       > | ||||
|         {!collapsed && ( | ||||
|           <Box textAlign="center" p={3} alignItems="center" minHeight={72}> | ||||
|             <img | ||||
|               src="Logo.png" | ||||
|               alt="Dream Views" | ||||
|             /> | ||||
|  | ||||
|             <ListItemIcon sx={{ color: '#DFCCBCFF' }}>{icon}</ListItemIcon> | ||||
|             <ListItemText | ||||
|               primary={text} | ||||
|               slotProps={{ | ||||
|                 primary: { | ||||
|                   sx: { | ||||
|                     color: '#DFCCBCFF', | ||||
|                     fontWeight: 'medium', | ||||
|                   }, | ||||
|                 }, | ||||
|             <InputBase | ||||
|               placeholder="Filter options..." | ||||
|               sx={{ | ||||
|                 pl: 1.5, | ||||
|                 pr: 1.5, | ||||
|                 py: 0.75, | ||||
|                 borderRadius: 2, | ||||
|                 border: '1px solid #40120EFF', | ||||
|                 color: '#40120EFF', | ||||
|                 width: '100%', | ||||
|               }} | ||||
|             /> | ||||
|           </ListItem> | ||||
|         ))} | ||||
|           </Box> | ||||
|         )} | ||||
|       </Box> | ||||
|  | ||||
|       {collapsed && ( | ||||
|         <Box textAlign="center" p={3} minHeight={112} justifyContent="center" display="flex" | ||||
|           alignItems="start"> | ||||
|           <img | ||||
|             style={{ marginTop: 5 }} | ||||
|             src="MiniLogo.png" | ||||
|             alt="Dream Views" | ||||
|           /> | ||||
|         </Box> | ||||
|       )} | ||||
|  | ||||
|       <List sx={{ | ||||
|         width: '100%', | ||||
|         py: 0, | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         alignItems: 'stretch' | ||||
|       }}> | ||||
|         {items.map(({ text, icon }, index) => { | ||||
|           const isCatalog = text === 'Catalog'; | ||||
|  | ||||
|           if (isCatalog) { | ||||
|             return ( | ||||
|               <Box key={`catalog-${index}`}> | ||||
|                 <Tooltip | ||||
|                   title={collapsed ? text : ''} | ||||
|                   placement="right" | ||||
|                   disableHoverListener={!collapsed} | ||||
|                 > | ||||
|                   <ListItem | ||||
|                     onClick={() => { | ||||
|                       if (collapsed) { | ||||
|                         setCollapsed(false); | ||||
|                         setOpenCatalog(true); | ||||
|                       } else { | ||||
|                         setOpenCatalog((v) => !v); | ||||
|                       } | ||||
|                     }} | ||||
|                     sx={{ | ||||
|                       px: collapsed ? 0 : 2, | ||||
|                       minHeight: collapsed ? 48 : 44, | ||||
|                       cursor: 'pointer', | ||||
|                       justifyContent: collapsed ? 'center' : 'flex-start', | ||||
|                       width: '100%', | ||||
|                     }} | ||||
|                   > | ||||
|                     <ListItemIcon | ||||
|                       sx={{ | ||||
|                         color: '#40120EFF', | ||||
|                         minWidth: collapsed ? 'auto' : 40, | ||||
|                         mr: collapsed ? 0 : 1.5, | ||||
|                         justifyContent: 'center', | ||||
|                       }} | ||||
|                     > | ||||
|                       {icon} | ||||
|                     </ListItemIcon> | ||||
|  | ||||
|                     {!collapsed && ( | ||||
|                       <> | ||||
|                         <ListItemText | ||||
|                           primary={text} | ||||
|                           slotProps={{ | ||||
|                             primary: { | ||||
|                               sx: { | ||||
|                                 color: '#40120EFF', | ||||
|                                 fontWeight: 'medium', | ||||
|                               }, | ||||
|                             }, | ||||
|                           }} | ||||
|                         /> | ||||
|                         {openCatalog ? <ExpandLess /> : <ExpandMore />} | ||||
|                       </> | ||||
|                     )} | ||||
|                   </ListItem> | ||||
|                 </Tooltip> | ||||
|  | ||||
|                 {!collapsed && ( | ||||
|                   <Collapse in={openCatalog} timeout="auto" unmountOnExit> | ||||
|                     <List component="div" disablePadding sx={{ pl: 7 }}> | ||||
|                       {catalogChildren.map((child) => ( | ||||
|                         <ListItemButton | ||||
|                           key={child} | ||||
|                           sx={{ py: 0.5, borderRadius: 1 }} | ||||
|                           onClick={() => { | ||||
|                             if (isMobile) onClose?.(); | ||||
|                             onSelect?.(child); | ||||
|                           }} | ||||
|                         > | ||||
|                           <ListItemText | ||||
|                             primary={child} | ||||
|                             slotProps={{ | ||||
|                               primary: { | ||||
|                                 sx: { color: '#40120EFF', fontWeight: 400 }, | ||||
|                               }, | ||||
|                             }} | ||||
|                           /> | ||||
|                         </ListItemButton> | ||||
|                       ))} | ||||
|                     </List> | ||||
|                   </Collapse> | ||||
|                 )} | ||||
|               </Box> | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|           return ( | ||||
|             <Tooltip | ||||
|               key={`${text}-${index}`} | ||||
|               title={collapsed ? (text || ' ') : ''} | ||||
|               placement="right" | ||||
|               disableHoverListener={!collapsed} | ||||
|             > | ||||
|               <ListItem | ||||
|                 onClick={() => { | ||||
|                   if (isMobile) onClose?.(); | ||||
|                   if (text === 'Logout') { | ||||
|                     logout(); | ||||
|                   } else { | ||||
|                     onSelect?.(text); | ||||
|                   } | ||||
|                 }} | ||||
|                 sx={{ | ||||
|                   px: collapsed ? 0 : 2, | ||||
|                   minHeight: collapsed ? 48 : 44, | ||||
|                   cursor: 'pointer', | ||||
|                   justifyContent: collapsed ? 'center' : 'flex-start', | ||||
|                   width: '100%', | ||||
|                 }} | ||||
|               > | ||||
|                 <ListItemIcon | ||||
|                   sx={{ | ||||
|                     color: '#40120EFF', | ||||
|                     minWidth: collapsed ? 'auto' : 40, | ||||
|                     mr: collapsed ? 0 : 1.5, | ||||
|                     justifyContent: 'center', | ||||
|                   }} | ||||
|                 > | ||||
|                   {icon} | ||||
|                 </ListItemIcon> | ||||
|                 {!collapsed && ( | ||||
|                   <ListItemText | ||||
|                     primary={text} | ||||
|                     slotProps={{ | ||||
|                       primary: { | ||||
|                         sx: { | ||||
|                           color: '#40120EFF', | ||||
|                           fontWeight: 'medium', | ||||
|                         }, | ||||
|                       }, | ||||
|                     }} | ||||
|                   /> | ||||
|                 )} | ||||
|               </ListItem> | ||||
|             </Tooltip> | ||||
|           ); | ||||
|         })} | ||||
|       </List> | ||||
|  | ||||
|       <Tooltip title={collapsed ? 'Expand' : 'Collapse'} placement="right"> | ||||
|         <IconButton onClick={() => setCollapsed((c) => !c)} sx={{ | ||||
|           backgroundColor: 'transparent', | ||||
|           color: 'transparent', | ||||
|           '&:hover': { | ||||
|             backgroundColor: '#fff4ec', | ||||
|             borderColor: 'transparent' | ||||
|           }, | ||||
|           borderRadius: 0, | ||||
|           marginLeft: 2, | ||||
|           width: 40, | ||||
|           height: 40, | ||||
|         }}> | ||||
|           <img | ||||
|             src={collapsed ? '/Expand.png' : '/Contract.png'} | ||||
|             alt={collapsed ? 'Expand' : 'Contract'} | ||||
|             width={24} | ||||
|             height={24} | ||||
|           /> | ||||
|         </IconButton> | ||||
|       </Tooltip> | ||||
|     </Drawer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										312
									
								
								src/components/MenuDrawerPrivate.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,312 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Drawer, List, ListItemButton, ListItemIcon, ListItemText, | ||||
|     Collapse, IconButton, Tooltip, Box, useMediaQuery, InputBase | ||||
| } from '@mui/material'; | ||||
| import { useTheme } from '@mui/material/styles'; | ||||
|  | ||||
| import InsightsIcon from '@mui/icons-material/Insights'; | ||||
| import Inventory2Icon from '@mui/icons-material/Inventory2'; | ||||
| import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; | ||||
| import BusinessIcon from '@mui/icons-material/Business'; | ||||
| import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; | ||||
| import SettingsIcon from '@mui/icons-material/Settings'; | ||||
| import ExpandLess from '@mui/icons-material/ExpandLess'; | ||||
| import ExpandMore from '@mui/icons-material/ExpandMore'; | ||||
|  | ||||
| export const OPEN_WIDTH = 450; | ||||
| export const MINI_WIDTH = 72; | ||||
|  | ||||
| const menuData = [ | ||||
|     { | ||||
|         title: 'Business Intelligence', | ||||
|         icon: <InsightsIcon />, | ||||
|         children: [ | ||||
|             { title: 'Sales Report' }, | ||||
|             { title: 'Customer Insights' }, | ||||
|             { title: 'Customer Insights 2' }, | ||||
|         ] | ||||
|     }, | ||||
|     { | ||||
|         title: 'Products Management', | ||||
|         icon: <Inventory2Icon />, | ||||
|         children: [ | ||||
|             { | ||||
|                 title: 'Catalog Management', | ||||
|                 children: [ | ||||
|                     { | ||||
|                         title: 'Category Dictionary', | ||||
|                         children: [ | ||||
|                             { title: 'Categories' } | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         title: 'Products', | ||||
|                         children: [ | ||||
|                             { title: 'AR  Assets Library Management' }, | ||||
|                             { title: 'Media Management' }, | ||||
|                         ] | ||||
|                     }, | ||||
|                     { title: 'Product Collections' }, | ||||
|                 ] | ||||
|             } | ||||
|         ] | ||||
|     }, | ||||
|     { | ||||
|         title: 'Customers', | ||||
|         icon: <PeopleAltIcon />, | ||||
|         children: [ | ||||
|             { | ||||
|                 title: 'CRM', | ||||
|                 children: [ | ||||
|                     { title: 'Customer List' }, | ||||
|                     { title: 'Projects' }, | ||||
|                     { title: 'Customer Collections' }, | ||||
|                 ] | ||||
|             }, | ||||
|  | ||||
|             { | ||||
|                 title: 'Sales', | ||||
|                 children: [ | ||||
|                     { title: 'Quotes' }, | ||||
|                     { title: 'Orders' }, | ||||
|                 ] | ||||
|             } | ||||
|         ] | ||||
|     }, | ||||
|     { | ||||
|         title: 'Providers (Brands and Clients)', | ||||
|         icon: <BusinessIcon />, | ||||
|         children: [ | ||||
|             { title: 'Brand Partners' }, | ||||
|             { title: 'Companies' }, | ||||
|             { title: 'Suppliers' }, | ||||
|             { title: 'Materials Providers' }, | ||||
|         ] | ||||
|     }, | ||||
|     { | ||||
|         title: 'Users', | ||||
|         icon: <AdminPanelSettingsIcon />, | ||||
|         children: [ | ||||
|             { title: 'Users Management' }, | ||||
|             { | ||||
|                 title: 'Access Control', | ||||
|                 children: [ | ||||
|                     { title: 'Roles' }, | ||||
|                     { title: 'Permissions' }, | ||||
|                 ] | ||||
|             }, | ||||
|         ] | ||||
|     }, | ||||
|     { | ||||
|         title: 'Settings', | ||||
|         icon: <SettingsIcon />, | ||||
|         children: [ | ||||
|             { title: 'General Settings' }, | ||||
|             { title: 'WebApp Configuration' }, | ||||
|             { title: 'Mobile App Configuration' }, | ||||
|         ] | ||||
|     }, | ||||
| ]; | ||||
|  | ||||
| export default function MenuDrawerPrivate({ | ||||
|     open, | ||||
|     onClose, | ||||
|     onSelect, | ||||
|     onExpandedChange, | ||||
| }) { | ||||
|     const theme = useTheme(); | ||||
|     const isMobile = useMediaQuery('(max-width:900px)'); | ||||
|  | ||||
|     const [collapsed, setCollapsed] = useState(false); | ||||
|     const [openMap, setOpenMap] = useState({}); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (!isMobile) return; | ||||
|         if (typeof open === 'boolean') { | ||||
|             setCollapsed(false); | ||||
|         } | ||||
|     }, [open, isMobile]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         onExpandedChange?.(isMobile ? true : !collapsed); | ||||
|     }, [collapsed, isMobile, onExpandedChange]); | ||||
|  | ||||
|     const paperWidth = isMobile ? OPEN_WIDTH : (collapsed ? MINI_WIDTH : OPEN_WIDTH); | ||||
|  | ||||
|     const toggleCollapse = () => setCollapsed(c => !c); | ||||
|  | ||||
|     const handleToggleNode = (key) => { | ||||
|         if (!isMobile && collapsed) { | ||||
|             setCollapsed(false); | ||||
|             setOpenMap((m) => ({ ...m, [key]: true })); | ||||
|             return; | ||||
|         } | ||||
|         setOpenMap((m) => ({ ...m, [key]: !m[key] })); | ||||
|     }; | ||||
|  | ||||
|     const renderNode = (node, keyPrefix = '') => { | ||||
|         const key = `${keyPrefix}${node.title}`; | ||||
|         const hasChildren = !!node.children?.length; | ||||
|  | ||||
|         return ( | ||||
|             <Box key={key}> | ||||
|                 <Tooltip title={collapsed ? node.title : ''} placement="right" disableHoverListener={!collapsed}> | ||||
|                     <ListItemButton | ||||
|                         onClick={() => { | ||||
|                             if (hasChildren) { | ||||
|                                 handleToggleNode(key); | ||||
|                             } else { | ||||
|                                 if (node.title === 'Users Management') { | ||||
|                                     onSelect?.('/Users/UserManagement'); | ||||
|                                 } else if (node.title === 'Product Collections') { | ||||
|                                     onSelect?.('/Products Management/Catalog Management/Product Collections'); | ||||
|                                 } else if (node.title === 'Categories') { | ||||
|                                     onSelect?.('/Products Management/Catalog Management/Categories'); | ||||
|                                 } else { | ||||
|                                     onSelect?.(node.title); | ||||
|                                 } | ||||
|                                 if (isMobile) onClose?.(); | ||||
|                             } | ||||
|                         }} | ||||
|                         sx={{ | ||||
|                             px: collapsed ? 0 : 2, | ||||
|                             minHeight: 48, | ||||
|                             justifyContent: collapsed ? 'center' : 'flex-start', | ||||
|                         }} | ||||
|                     > | ||||
|                         {node.icon && ( | ||||
|                             <ListItemIcon | ||||
|                                 sx={{ | ||||
|                                     color: '#40120EFF', | ||||
|                                     minWidth: collapsed ? 'auto' : 40, | ||||
|                                     mr: collapsed ? 0 : 1.5, | ||||
|                                     justifyContent: 'center', | ||||
|                                 }} | ||||
|                             > | ||||
|                                 {node.icon} | ||||
|                             </ListItemIcon> | ||||
|                         )} | ||||
|  | ||||
|                         {!collapsed && ( | ||||
|                             <> | ||||
|                                 <ListItemText | ||||
|                                     primary={node.title} | ||||
|                                     slotProps={{ | ||||
|                                         primary: { sx: { color: '#40120EFF', fontWeight: hasChildren ? 600 : 400, whiteSpace: 'nowrap' } } | ||||
|                                     }} | ||||
|                                 /> | ||||
|                                 {hasChildren ? (openMap[key] ? <ExpandLess /> : <ExpandMore />) : null} | ||||
|                             </> | ||||
|                         )} | ||||
|                     </ListItemButton> | ||||
|                 </Tooltip> | ||||
|  | ||||
|                 {hasChildren && !collapsed && ( | ||||
|                     <Collapse in={!!openMap[key]} timeout="auto" unmountOnExit> | ||||
|                         <List component="div" disablePadding sx={{ pl: 7 }}> | ||||
|                             {node.children.map((child) => renderNode(child, `${key}-`))} | ||||
|                         </List> | ||||
|                     </Collapse> | ||||
|                 )} | ||||
|             </Box> | ||||
|         ); | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <Drawer | ||||
|             anchor="left" | ||||
|             variant={isMobile ? 'temporary' : 'permanent'} | ||||
|             open={isMobile ? open : true} | ||||
|             onClose={isMobile ? onClose : undefined} | ||||
|             ModalProps={{ keepMounted: true }} | ||||
|             sx={{ | ||||
|                 width: paperWidth, | ||||
|                 flexShrink: 0, | ||||
|                 '& .MuiDrawer-paper': { | ||||
|                     width: paperWidth, | ||||
|                     boxSizing: 'border-box', | ||||
|                     backgroundColor: '#FFFFFF', | ||||
|                     color: '#40120EFF', | ||||
|                     transition: theme.transitions.create('width', { | ||||
|                         duration: theme.transitions.duration.standard, | ||||
|                         easing: theme.transitions.easing.sharp, | ||||
|                     }), | ||||
|                     borderRight: '1px solid rgba(0,0,0,0.08)', | ||||
|                 }, | ||||
|             }} | ||||
|         > | ||||
|  | ||||
|             <Box | ||||
|                 sx={{ | ||||
|                     display: 'flex', | ||||
|                     alignItems: 'center', | ||||
|                     gap: 1, | ||||
|                     px: collapsed ? 1 : 2, | ||||
|                     py: 1.5, | ||||
|                     justifyContent: collapsed ? 'center' : 'space-between', | ||||
|                 }} | ||||
|             > | ||||
|                 {!collapsed && ( | ||||
|                     <Box textAlign="center" p={3} alignItems="center" minHeight={72}> | ||||
|                         <img | ||||
|                             src="Logo.png" | ||||
|                             alt="Dream Views" | ||||
|                         /> | ||||
|  | ||||
|                         <InputBase | ||||
|                             placeholder="Filter options..." | ||||
|                             sx={{ | ||||
|                                 pl: 1.5, | ||||
|                                 pr: 1.5, | ||||
|                                 py: 0.75, | ||||
|                                 borderRadius: 2, | ||||
|                                 border: '1px solid #40120EFF', | ||||
|                                 color: '#40120EFF', | ||||
|                                 width: '100%', | ||||
|                             }} | ||||
|                         /> | ||||
|                     </Box> | ||||
|                 )} | ||||
|             </Box> | ||||
|  | ||||
|             {collapsed && ( | ||||
|                 <Box textAlign="center" p={3} minHeight={112} justifyContent="center" display="flex" | ||||
|                     alignItems="start"> | ||||
|                     <img | ||||
|                         style={{ marginTop: 5 }} | ||||
|                         src="MiniLogo.png" | ||||
|                         alt="Dream Views" | ||||
|                     /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             {/* Tree */} | ||||
|             <List sx={{ width: '100%', py: 0 }}> | ||||
|                 {menuData.map((node) => renderNode(node))} | ||||
|             </List> | ||||
|  | ||||
|             <Tooltip title={collapsed ? 'Expand' : 'Collapse'} placement="right"> | ||||
|                 <IconButton onClick={() => setCollapsed((c) => !c)} sx={{ | ||||
|                     backgroundColor: 'transparent', | ||||
|                     color: 'transparent', | ||||
|                     '&:hover': { | ||||
|                         backgroundColor: '#fff4ec', | ||||
|                         borderColor: 'transparent' | ||||
|                     }, | ||||
|                     borderRadius: 0, | ||||
|                     marginLeft: 2, | ||||
|                     width: 40, | ||||
|                     height: 40, | ||||
|                 }}> | ||||
|                     <img | ||||
|                         src={collapsed ? '/Expand.png' : '/Contract.png'} | ||||
|                         alt={collapsed ? 'Expand' : 'Contract'} | ||||
|                         width={24} | ||||
|                         height={24} | ||||
|                     /> | ||||
|                 </IconButton> | ||||
|             </Tooltip> | ||||
|         </Drawer> | ||||
|     ); | ||||
| } | ||||
| @@ -1,46 +0,0 @@ | ||||
| export default function VimeoBackground({ videoId = "1066622045", opacity = 0.5 }) { | ||||
|   const params = new URLSearchParams({ | ||||
|     background: "1", | ||||
|     muted: "1", | ||||
|     autoplay: "1", | ||||
|     autopause: "0", | ||||
|     controls: "0", | ||||
|     loop: "1", | ||||
|     dnt: "1", | ||||
|     playsinline: "1", | ||||
|     app_id: "122963", | ||||
|   }).toString(); | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       style={{ | ||||
|         position: "fixed", | ||||
|         top: 0, | ||||
|         left: 0, | ||||
|         width: "100vw", | ||||
|         height: "100vh", | ||||
|         zIndex: -1, | ||||
|         overflow: "hidden", | ||||
|         pointerEvents: "none", | ||||
|       }} | ||||
|     > | ||||
|       <iframe | ||||
|         src={`https://player.vimeo.com/video/${videoId}?${params}`} | ||||
|         style={{ | ||||
|           position: "absolute", | ||||
|           top: "50%", | ||||
|           left: "50%", | ||||
|           width: "177.78vh", // 100vh * 16 / 9 | ||||
|           height: "100vh", | ||||
|           transform: "translate(-50%, -50%)", | ||||
|           opacity, | ||||
|           border: "none", | ||||
|         }} | ||||
|         allow="autoplay; fullscreen; picture-in-picture" | ||||
|         frameBorder="0" | ||||
|         allowFullScreen | ||||
|         title="Vimeo Background Video" | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/context/AuthContext.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | ||||
| import { createContext, useContext, useState, useEffect } from 'react'; | ||||
|  | ||||
| const AuthContext = createContext(); | ||||
|  | ||||
| export function AuthProvider({ children }) { | ||||
|   const [user, setUser] = useState(null); | ||||
|   const [initializing, setInitializing] = useState(true); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const storedUser = localStorage.getItem('user'); | ||||
|     if (storedUser) setUser(JSON.parse(storedUser)); | ||||
|     setInitializing(false); | ||||
|   }, []); | ||||
|  | ||||
|   const login = (userData) => { | ||||
|     setUser(userData); | ||||
|     localStorage.setItem('user', JSON.stringify(userData)); | ||||
|     console.log('User logged in:', userData); | ||||
|   }; | ||||
|  | ||||
|   const logout = () => { | ||||
|     setUser(null); | ||||
|     localStorage.removeItem('user'); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <AuthContext.Provider value={{ user, login, logout, initializing }}> | ||||
|       {children} | ||||
|     </AuthContext.Provider> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useAuth() { | ||||
|   return useContext(AuthContext); | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/hooks/useApiToast.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| import { useSnackbar } from 'notistack'; | ||||
|  | ||||
| export default function useApiToast() { | ||||
|     const { enqueueSnackbar } = useSnackbar(); | ||||
|  | ||||
|     const handleError = (error, defaultMessage = 'API error') => { | ||||
|         console.error(error); | ||||
|         enqueueSnackbar(defaultMessage, { variant: 'error' }); | ||||
|     }; | ||||
|  | ||||
|     return { handleError }; | ||||
| } | ||||
							
								
								
									
										17
									
								
								src/main.jsx
									
									
									
									
									
								
							
							
						
						| @@ -1,15 +1,26 @@ | ||||
| import { StrictMode } from 'react' | ||||
| import { createRoot } from 'react-dom/client' | ||||
|  | ||||
| import { SnackbarProvider } from 'notistack'; | ||||
| import { ThemeProvider } from '@mui/material/styles'; | ||||
| import theme from './theme'; | ||||
| import './index.css' | ||||
| import App from './App.jsx' | ||||
| import { GoogleOAuthProvider } from '@react-oauth/google'; | ||||
| import { AuthProvider } from './context/AuthContext'; | ||||
| import { BrowserRouter } from 'react-router-dom'; | ||||
|  | ||||
| createRoot(document.getElementById('root')).render( | ||||
|   <StrictMode> | ||||
|     <ThemeProvider theme={theme}> | ||||
|       <App /> | ||||
|       <SnackbarProvider maxSnack={3} autoHideDuration={3000}> | ||||
|         <GoogleOAuthProvider clientId="128345072002-mtfdgpcur44o9tbd7q6e0bb9qnp2crfp.apps.googleusercontent.com"> | ||||
|           <AuthProvider> | ||||
|             <BrowserRouter> | ||||
|               <App /> | ||||
|             </BrowserRouter> | ||||
|           </AuthProvider> | ||||
|         </GoogleOAuthProvider> | ||||
|       </SnackbarProvider> | ||||
|     </ThemeProvider> | ||||
|   </StrictMode>, | ||||
| ) | ||||
| ) | ||||
							
								
								
									
										72
									
								
								src/private/LoginPage.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,72 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import { GoogleLogin } from '@react-oauth/google'; | ||||
| import { jwtDecode } from 'jwt-decode'; | ||||
| import ThalosTokenConnector from '../auth/ThalosTokenConnector'; | ||||
| import { useAuth } from '../context/AuthContext'; | ||||
| import { Box, Paper, Typography } from '@mui/material'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
|  | ||||
| export default function LoginPage() { | ||||
|     const [googleIdToken, setGoogleIdToken] = useState(null); | ||||
|     const [googleProfile, setGoogleProfile] = useState(null); | ||||
|     const { login } = useAuth(); | ||||
|     const navigate = useNavigate(); | ||||
|  | ||||
|     return ( | ||||
|         <Box display="flex" justifyContent="center" alignItems="center" height="100vh"> | ||||
|             <Paper sx={{ p: 4, borderRadius: 2, boxShadow: 3, textAlign: 'center' }}> | ||||
|  | ||||
|                 <Box mb={2}> | ||||
|                     <Typography variant="h5" mb={2}>Login</Typography> | ||||
|                     <img | ||||
|                         src="/Logo.png" | ||||
|                         alt="Dream Views" | ||||
|                         style={{ width: '200px', height: 'auto' }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|                 <GoogleLogin | ||||
|                     onSuccess={(cred) => { | ||||
|                         const idToken = cred?.credential; | ||||
|                         if (!idToken) return; | ||||
|  | ||||
|                         const decoded = jwtDecode(idToken); | ||||
|                         setGoogleIdToken(idToken); | ||||
|                         setGoogleProfile({ | ||||
|                             name: decoded?.name || '', | ||||
|                             email: decoded?.email || '', | ||||
|                             picture: decoded?.picture || '', | ||||
|                         }); | ||||
|                     }} | ||||
|                     onError={() => console.error('Google login failed')} | ||||
|                 /> | ||||
|  | ||||
|                 {googleIdToken && ( | ||||
|                     <ThalosTokenConnector | ||||
|                         googleIdToken={googleIdToken} | ||||
|                         onSuccess={(payload) => { | ||||
|                             login({ | ||||
|                                 ...googleProfile, | ||||
|                                 idToken: googleIdToken, | ||||
|                                 thalosToken: payload?.token || '', | ||||
|                             }); | ||||
|                             navigate('/', { replace: true }); | ||||
|                         }} | ||||
|                         onError={(err) => { | ||||
|                             console.error('Thalos exchange failed:', err); | ||||
|                             localStorage.removeItem('thalosToken'); | ||||
|                         }} | ||||
|                     /> | ||||
|                 )} | ||||
|  | ||||
|                 <Box mt={4}> | ||||
|                     <p>By</p> | ||||
|                     <img | ||||
|                         src="https://imaageq.com/images/Imaageq%20black-no%20slogan.webp" | ||||
|                         alt="ImaageQ" | ||||
|                         style={{ width: '72px', height: 'auto', opacity: 0.8 }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|             </Paper> | ||||
|         </Box> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										391
									
								
								src/private/catalogs/categories/AddOrEditCategoryForm.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,391 @@ | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { Box, Button, Paper, TextField, Typography, MenuItem, Chip } from '@mui/material'; | ||||
| import { useAuth } from '../../../context/AuthContext'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import TagTypeApi from '../../../api/TagTypeApi'; | ||||
| import { jwtDecode } from 'jwt-decode'; | ||||
|  | ||||
| function slugify(s) { | ||||
|   return (s || '') | ||||
|     .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') | ||||
|     .toLowerCase().trim() | ||||
|     .replace(/[^a-z0-9]+/g, '-') | ||||
|     .replace(/^-+|-+$/g, ''); | ||||
| } | ||||
|  | ||||
| function extractTenantId(token) { | ||||
|   try { | ||||
|     const payload = jwtDecode(token); | ||||
|     const t = payload?.tenant; | ||||
|     if (Array.isArray(t)) { | ||||
|       const hex = t.find(x => typeof x === 'string' && /^[0-9a-f]{24}$/i.test(x)); | ||||
|       return hex || (typeof t[0] === 'string' ? t[0] : ''); | ||||
|     } | ||||
|     if (typeof t === 'string') return t; | ||||
|   } catch { } | ||||
|   return ''; | ||||
| } | ||||
|  | ||||
| function formatDateSafe(value) { | ||||
|   if (!value) return '—'; | ||||
|   // Accept Date instance, ISO string, or numeric timestamp | ||||
|   const d = value instanceof Date ? value : new Date(value); | ||||
|   if (Number.isNaN(d.getTime())) return '—'; | ||||
|   // Treat placeholder/default dates as empty | ||||
|   const year = d.getUTCFullYear(); | ||||
|   if (year <= 1971) return '—'; | ||||
|   return new Intl.DateTimeFormat(undefined, { | ||||
|     year: 'numeric', | ||||
|     month: 'short', | ||||
|     day: '2-digit', | ||||
|     hour: '2-digit', | ||||
|     minute: '2-digit' | ||||
|   }).format(d); | ||||
| } | ||||
|  | ||||
| export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel, materials: materialsProp = [], initialMaterialNames = [], viewOnly = false }) { | ||||
|   const { user } = useAuth(); | ||||
|   const token = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|   const api = useMemo(() => new CategoriesApi(token), [token]); | ||||
|   const tagTypeApi = useMemo(() => new TagTypeApi(token), [token]); | ||||
|  | ||||
|   const [types, setTypes] = useState([]); | ||||
|   const [allTags, setAllTags] = useState([]); | ||||
|  | ||||
|   const tagLabelById = useMemo(() => { | ||||
|     const map = {}; | ||||
|     for (const t of allTags) { | ||||
|       const key = t._id; | ||||
|       map[key] = t.tagName || t.name || key; | ||||
|     } | ||||
|     return map; | ||||
|   }, [allTags]); | ||||
|  | ||||
|   const [form, setForm] = useState({ | ||||
|     _id: '', | ||||
|     id: '', | ||||
|     tenantId: '', | ||||
|     tagName: '', | ||||
|     typeId: '', | ||||
|     parentTagId: [], | ||||
|     slug: '', | ||||
|     displayOrder: 0, | ||||
|     icon: '', | ||||
|     status: 'Active', | ||||
|     createdAt: null, | ||||
|     createdBy: null, | ||||
|     updatedAt: null, | ||||
|     updatedBy: null, | ||||
|   }); | ||||
|  | ||||
|   // cargar tipos (Tag Types) y tags para selects | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       try { | ||||
|         // Load all tag types from TagTypeApi | ||||
|         const typesResp = await tagTypeApi.getAll(); | ||||
|         setTypes(Array.isArray(typesResp) ? typesResp : []); | ||||
|  | ||||
|         // Load all existing tags (used to resolve parentTagId -> labels for Material) | ||||
|         const tags = typeof api.getAll === 'function' ? await api.getAll() : []; | ||||
|         setAllTags(Array.isArray(tags) ? tags : []); | ||||
|       } catch (e) { | ||||
|         console.error('Failed to load tag types or tags', e); | ||||
|         setTypes([]); | ||||
|         setAllTags([]); | ||||
|       } | ||||
|     })(); | ||||
|   }, [tagTypeApi, api]); | ||||
|  | ||||
|   // When editing: if we received material names from the grid, map them to IDs once allTags are loaded. | ||||
|   useEffect(() => { | ||||
|     if (!Array.isArray(initialMaterialNames) || initialMaterialNames.length === 0) return; | ||||
|     // If parentTagId already has values (ids), do not override. | ||||
|     if (Array.isArray(form.parentTagId) && form.parentTagId.length > 0) return; | ||||
|     if (!Array.isArray(allTags) || allTags.length === 0) return; | ||||
|  | ||||
|     // Build a case-insensitive name -> id map | ||||
|     const nameToId = new Map( | ||||
|       allTags.map(t => { | ||||
|         const _id = t._id; | ||||
|         const label = (t.tagName || t.name || '').toLowerCase(); | ||||
|         return [label, _id]; | ||||
|       }) | ||||
|     ); | ||||
|  | ||||
|     const ids = initialMaterialNames | ||||
|       .map(n => (typeof n === 'string' ? n.toLowerCase() : '')) | ||||
|       .map(lower => nameToId.get(lower)) | ||||
|       .filter(Boolean); | ||||
|  | ||||
|     if (ids.length > 0) { | ||||
|       setForm(prev => ({ ...prev, parentTagId: ids })); | ||||
|     } | ||||
|   }, [initialMaterialNames, allTags]); | ||||
|  | ||||
|   // set inicial | ||||
|   useEffect(() => { | ||||
|     if (initialData) { | ||||
|       setForm({ | ||||
|         _id: initialData._id, | ||||
|         id: initialData.id, | ||||
|         tenantId: initialData.tenantId || extractTenantId(token) || '', | ||||
|         tagName: initialData.tagName || initialData.name || '', | ||||
|         typeId: initialData.typeId || '', | ||||
|         parentTagId: Array.isArray(initialData.parentTagId) ? initialData.parentTagId : [], | ||||
|         slug: initialData.slug || slugify(initialData.tagName || initialData.name || ''), | ||||
|         displayOrder: Number(initialData.displayOrder ?? 0), | ||||
|         icon: initialData.icon || '', | ||||
|         status: initialData.status || 'Active', | ||||
|         createdAt: initialData.createdAt ?? null, | ||||
|         createdBy: initialData.createdBy ?? null, | ||||
|         updatedAt: initialData.updatedAt ?? null, | ||||
|         updatedBy: initialData.updatedBy ?? null, | ||||
|       }); | ||||
|     } else { | ||||
|       setForm({ | ||||
|         _id: '', | ||||
|         id: '', | ||||
|         tenantId: extractTenantId(token) || '', | ||||
|         tagName: '', | ||||
|         typeId: '', | ||||
|         parentTagId: [], | ||||
|         slug: '', | ||||
|         displayOrder: 0, | ||||
|         icon: '', | ||||
|         status: 'Active', | ||||
|         createdAt: null, | ||||
|         createdBy: null, | ||||
|         updatedAt: null, | ||||
|         updatedBy: null, | ||||
|       }); | ||||
|     } | ||||
|   }, [initialData]); | ||||
|  | ||||
|   const isEdit = Boolean(form._id); | ||||
|   const isAdd = !isEdit; | ||||
|  | ||||
|   const setVal = (name, value) => setForm(p => ({ ...p, [name]: value })); | ||||
|  | ||||
|   const handleChange = (e) => { | ||||
|     const { name, value } = e.target; | ||||
|     setVal(name, value); | ||||
|     if (name === 'tagName' && !form._id) { | ||||
|       setVal('slug', slugify(value)); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       const tenantId = extractTenantId(token); | ||||
|  | ||||
|       if (!form.tagName?.trim()) throw new Error('Tag name is required'); | ||||
|       if (!form.typeId) throw new Error('Type is required'); | ||||
|       if (!form.icon?.trim()) throw new Error('Icon is required'); | ||||
|       if (!tenantId) throw new Error('TenantId not found in token'); | ||||
|  | ||||
|       const base = { | ||||
|         id: form.id?.trim() || undefined, | ||||
|         tagName: form.tagName.trim(), | ||||
|         typeId: form.typeId, | ||||
|         parentTagId: form.parentTagId, | ||||
|         slug: form.slug.trim() || slugify(form.tagName), | ||||
|         displayOrder: Number(form.displayOrder) || 0, | ||||
|         icon: form.icon.trim(), | ||||
|         status: form.status || 'Active', | ||||
|         tenantId, // requerido por backend (400 si falta) | ||||
|       }; | ||||
|  | ||||
|       if (form._id) { | ||||
|         const idForUpdate = Boolean(form._id) ? String(form._id) : null; | ||||
|         if (!idForUpdate) throw new Error('Missing _id for update'); | ||||
|         const payload = { | ||||
|           _id: idForUpdate, | ||||
|           ...base, | ||||
|         }; | ||||
|         console.log('[CategoryForm] SUBMIT (edit) with _id:', idForUpdate, 'payload:', payload); | ||||
|         await api.update(payload); | ||||
|       } else { | ||||
|         await api.create(base); | ||||
|       } | ||||
|  | ||||
|       // Ensure the parent refresh (loadData) happens before closing the dialog | ||||
|       if (onAdd) { | ||||
|         await onAdd(); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error('Submit category failed:', e); | ||||
|       alert(e.message || 'Submit failed'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDelete = async () => { | ||||
|     try { | ||||
|       const idToUse = form._id; | ||||
|       if (!idToUse) throw new Error('Missing _id to delete'); | ||||
|       console.debug('[CategoryForm] DELETE with _id:', idToUse); | ||||
|       await api.changeStatus({ id: idToUse, status: 'Inactive' }); | ||||
|       if (onAdd) { | ||||
|         await onAdd(); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error('Delete category failed:', e); | ||||
|       alert(e.message || 'Delete failed'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Paper sx={{ p: 2 }}> | ||||
|       <Typography variant="subtitle1" sx={{ mb: 2 }}> | ||||
|         {form._id ? 'Edit Category' : 'Add Category'} | ||||
|       </Typography> | ||||
|  | ||||
|       {isAdd && ( | ||||
|         <TextField | ||||
|           name="tenantId" | ||||
|           label="Tenant Id" | ||||
|           value={form.tenantId} | ||||
|           fullWidth | ||||
|           sx={{ mb: 2 }} | ||||
|           InputProps={{ readOnly: true }} | ||||
|           disabled={viewOnly} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <TextField | ||||
|         name="tagName" | ||||
|         label="Name" | ||||
|         value={form.tagName} | ||||
|         onChange={handleChange} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         required | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         name="typeId" | ||||
|         label="Category" | ||||
|         value={form.typeId} | ||||
|         onChange={handleChange} | ||||
|         select | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         required | ||||
|         disabled={viewOnly} | ||||
|       > | ||||
|         {types.map((t) => { | ||||
|           const value = t._id; | ||||
|           const label = t.typeName || value; | ||||
|           return ( | ||||
|             <MenuItem key={value} value={value}> | ||||
|               {label} | ||||
|             </MenuItem> | ||||
|           ); | ||||
|         })} | ||||
|       </TextField> | ||||
|  | ||||
|       <TextField | ||||
|         name="parentTagId" | ||||
|         label="Material" | ||||
|         value={form.parentTagId} | ||||
|         onChange={(e) => { | ||||
|           // For MUI Select multiple, e.target.value is an array of selected IDs | ||||
|           const val = e.target.value; | ||||
|           setVal('parentTagId', Array.isArray(val) ? val : []); | ||||
|         }} | ||||
|         select | ||||
|         SelectProps={{ | ||||
|           multiple: true, | ||||
|           renderValue: (selected) => ( | ||||
|             <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> | ||||
|               {selected.map((id) => ( | ||||
|                 <Chip key={id} label={tagLabelById[id] || id} size="small" /> | ||||
|               ))} | ||||
|             </Box> | ||||
|           ), | ||||
|         }} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       > | ||||
|         {allTags.map((t) => { | ||||
|           const value = t._id; | ||||
|           const label = t.tagName || t.name || value; | ||||
|           return ( | ||||
|             <MenuItem key={value} value={value}> | ||||
|               {label} | ||||
|             </MenuItem> | ||||
|           ); | ||||
|         })} | ||||
|       </TextField> | ||||
|  | ||||
|       <TextField | ||||
|         name="slug" | ||||
|         label="Slug" | ||||
|         value={form.slug} | ||||
|         onChange={handleChange} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         name="displayOrder" | ||||
|         label="Display order" | ||||
|         type="number" | ||||
|         value={form.displayOrder} | ||||
|         onChange={handleChange} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         name="icon" | ||||
|         label="Icon URL" | ||||
|         value={form.icon} | ||||
|         onChange={handleChange} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         required | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         name="status" | ||||
|         label="Status" | ||||
|         value={form.status} | ||||
|         onChange={handleChange} | ||||
|         select | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       > | ||||
|         <MenuItem value="Active">Active</MenuItem> | ||||
|         <MenuItem value="Inactive">Inactive</MenuItem> | ||||
|       </TextField> | ||||
|  | ||||
|       {form._id ? ( | ||||
|         <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mt: 2 }}> | ||||
|           <TextField label="Created At" value={formatDateSafe(form.createdAt)} InputProps={{ readOnly: true }} fullWidth /> | ||||
|           <TextField label="Created By" value={form.createdBy ?? '—'} InputProps={{ readOnly: true }} fullWidth /> | ||||
|           <TextField label="Updated At" value={formatDateSafe(form.updatedAt)} InputProps={{ readOnly: true }} fullWidth /> | ||||
|           <TextField label="Updated By" value={form.updatedBy ?? '—'} InputProps={{ readOnly: true }} fullWidth /> | ||||
|         </Box> | ||||
|       ) : null} | ||||
|  | ||||
|       <Box display="flex" justifyContent="space-between" gap={1} mt={3}> | ||||
|         {form._id && !viewOnly ? ( | ||||
|           <Button color="error" onClick={handleDelete}>Delete</Button> | ||||
|         ) : <span />} | ||||
|         <Box sx={{ display: 'flex', gap: 1 }}> | ||||
|           <Button onClick={onCancel} className="button-transparent">{viewOnly ? 'Close' : 'Cancel'}</Button> | ||||
|           {!viewOnly && ( | ||||
|             <Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button> | ||||
|           )} | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Paper> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										380
									
								
								src/private/catalogs/categories/Categories.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,380 @@ | ||||
| import { useEffect, useMemo, useRef, useState } from 'react'; | ||||
| import { | ||||
|   Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography, | ||||
|   ToggleButton, ToggleButtonGroup | ||||
| } from '@mui/material'; | ||||
| import { DataGrid } from '@mui/x-data-grid'; | ||||
| import EditRoundedIcon from '@mui/icons-material/EditRounded'; | ||||
| import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; | ||||
| import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; | ||||
| import AddOrEditCategoryForm from './AddOrEditCategoryForm'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import { useAuth } from '../../../context/AuthContext'; | ||||
|  | ||||
| export default function Categories() { | ||||
|   const { user } = useAuth(); | ||||
|   const token = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|   const api = useMemo(() => new CategoriesApi(token), [token]); | ||||
|  | ||||
|   const [rows, setRows] = useState([]); | ||||
|   const [allTags, setAllTags] = useState([]); | ||||
|   const [statusFilter, setStatusFilter] = useState('All'); // <- por defecto All | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [editingCategory, setEditingCategory] = useState(null); | ||||
|   const [confirmOpen, setConfirmOpen] = useState(false); | ||||
|   const [rowToDelete, setRowToDelete] = useState(null); | ||||
|   const [viewOnly, setViewOnly] = useState(false); | ||||
|   const hasLoaded = useRef(false); | ||||
|  | ||||
|   const pageSize = 100; // Número de filas por página | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (!hasLoaded.current) { | ||||
|       loadData(); | ||||
|       hasLoaded.current = true; | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   const loadData = async () => { | ||||
|     try { | ||||
|       const data = await api.getAll(); | ||||
|       const list = Array.isArray(data) ? data : []; | ||||
|  | ||||
|       setAllTags(list); | ||||
|  | ||||
|       // Build a map of tagId -> tagName to resolve parent names | ||||
|       const idToName = {}; | ||||
|       for (const item of list) { | ||||
|         const key = item?._id || item?.id; | ||||
|         if (key) idToName[key] = item?.tagName || item?.name || ''; | ||||
|       } | ||||
|  | ||||
|       // Enrich each row with `materialNames`: names of the parents referenced by parentTagId | ||||
|       const enriched = list.map((r) => { | ||||
|         const parents = Array.isArray(r?.parentTagId) ? r.parentTagId : []; | ||||
|         const materialNames = parents | ||||
|           .map((pid) => idToName[pid]) | ||||
|           .filter(Boolean); | ||||
|  | ||||
|         return { | ||||
|           ...r, | ||||
|           materialNames, // array of strings | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|       setRows(enriched); | ||||
|     } catch (e) { | ||||
|       console.error('Failed to load categories:', e); | ||||
|       setRows([]); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleAddClick = () => { | ||||
|     setViewOnly(false); | ||||
|     setEditingCategory(null); | ||||
|     setOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleEditClick = (params) => { | ||||
|     setViewOnly(false); | ||||
|     const r = params?.row; | ||||
|     if (!r) return; | ||||
|     setEditingCategory({ | ||||
|       _id: String(r._id || ''), | ||||
|       id: String(r.id || ''), | ||||
|       tagName: r.tagName || r.name || '', | ||||
|       typeId: r.typeId || '', | ||||
|       parentTagId: Array.isArray(r.parentTagId) ? r.parentTagId : [], | ||||
|       slug: r.slug || '', | ||||
|       displayOrder: Number(r.displayOrder ?? 0), | ||||
|       icon: r.icon || '', | ||||
|       status: r.status ?? 'Active', | ||||
|       materialNames: Array.isArray(r.materialNames) | ||||
|         ? r.materialNames | ||||
|         : (typeof r.material === 'string' | ||||
|           ? r.material.split(',').map(s => s.trim()).filter(Boolean) | ||||
|           : []), | ||||
|       createdAt: r.createdAt ?? null, | ||||
|       createdBy: r.createdBy ?? null, | ||||
|       updatedAt: r.updatedAt ?? null, | ||||
|       updatedBy: r.updatedBy ?? null, | ||||
|     }); | ||||
|     setOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleDeleteClick = (row) => { | ||||
|     if (!row) return; | ||||
|     setRowToDelete(row); | ||||
|     setConfirmOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const pickHexId = (r) => | ||||
|     [r?._id, r?.id] | ||||
|       .filter(Boolean) | ||||
|       .find((x) => typeof x === 'string' && /^[0-9a-f]{24}$/i.test(x)) || null; | ||||
|  | ||||
|   const confirmDelete = async () => { | ||||
|     try { | ||||
|       if (!rowToDelete) return; | ||||
|       const hexId = pickHexId(rowToDelete); | ||||
|       if (!hexId) { | ||||
|         alert('No se encontró _id (24-hex) para ChangeStatus en esta fila.'); | ||||
|         return; | ||||
|       } | ||||
|       await api.changeStatus({ id: hexId, status: 'Inactive' }); | ||||
|       await loadData(); | ||||
|     } catch (e) { | ||||
|       console.error('Delete failed:', e); | ||||
|       alert('Delete failed. Revisa la consola para más detalles.'); | ||||
|     } finally { | ||||
|       setConfirmOpen(false); | ||||
|       setRowToDelete(null); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleFormDone = async () => { | ||||
|     await loadData(); | ||||
|     setOpen(false); | ||||
|     setEditingCategory(null); | ||||
|   }; | ||||
|  | ||||
|   // --- FILTRO DE ESTADO --- | ||||
|   const filteredRows = useMemo(() => { | ||||
|     if (statusFilter === 'All') return rows; | ||||
|     const want = String(statusFilter).toLowerCase(); | ||||
|     return rows.filter((r) => String(r?.status ?? 'Active').toLowerCase() === want); | ||||
|   }, [rows, statusFilter]); | ||||
|  | ||||
|   const columns = [ | ||||
|     { | ||||
|       field: 'actions', | ||||
|       headerName: '', | ||||
|       width: 150, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|       disableExport: true, | ||||
|       renderCell: (params) => ( | ||||
|         <Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#DFCCBC', | ||||
|               color: '#26201A', | ||||
|               '&:hover': { backgroundColor: '#C2B2A4' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => handleEditClick(params)} | ||||
|           > | ||||
|             <EditRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#E3F2FD', | ||||
|               color: '#1565C0', | ||||
|               '&:hover': { backgroundColor: '#BBDEFB' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { | ||||
|               const r = params?.row; | ||||
|               if (!r) return; | ||||
|               setEditingCategory({ | ||||
|                 _id: String(r._id || ''), | ||||
|                 id: String(r.id || ''), | ||||
|                 tagName: r.tagName || r.name || '', | ||||
|                 typeId: r.typeId || '', | ||||
|                 parentTagId: Array.isArray(r.parentTagId) ? r.parentTagId : [], | ||||
|                 slug: r.slug || '', | ||||
|                 displayOrder: Number(r.displayOrder ?? 0), | ||||
|                 icon: r.icon || '', | ||||
|                 status: r.status ?? 'Active', | ||||
|                 materialNames: Array.isArray(r.materialNames) | ||||
|                   ? r.materialNames | ||||
|                   : (typeof r.material === 'string' | ||||
|                     ? r.material.split(',').map(s => s.trim()).filter(Boolean) | ||||
|                     : []), | ||||
|                 createdAt: r.createdAt ?? null, | ||||
|                 createdBy: r.createdBy ?? null, | ||||
|                 updatedAt: r.updatedAt ?? null, | ||||
|                 updatedBy: r.updatedBy ?? null, | ||||
|               }); | ||||
|               setViewOnly(true); | ||||
|               setOpen(true); | ||||
|             }} | ||||
|           > | ||||
|             <VisibilityRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#FBE9E7', | ||||
|               color: '#C62828', | ||||
|               '&:hover': { backgroundColor: '#EF9A9A' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => handleDeleteClick(params?.row)} | ||||
|           > | ||||
|             <DeleteRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|         </Box> | ||||
|       ), | ||||
|     }, | ||||
|     { field: 'tagName', headerName: 'Name', flex: 1.2, minWidth: 180 }, | ||||
|     { field: 'slug', headerName: 'Slug', flex: 1.0, minWidth: 160 }, | ||||
|     { field: 'icon', headerName: 'Icon', flex: 0.7, minWidth: 250 }, | ||||
|  | ||||
|     /* | ||||
|     { field: 'material', headerName: 'Material', flex: 1.2, minWidth: 200 }, | ||||
|     */ | ||||
|     { | ||||
|       field: 'materialNames', | ||||
|       headerName: 'Material', | ||||
|       flex: 1.2, | ||||
|       minWidth: 220, | ||||
|       renderCell: (params) => { | ||||
|         const vals = Array.isArray(params?.row?.materialNames) ? params.row.materialNames : []; | ||||
|         return ( | ||||
|           <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}> | ||||
|             {vals.length ? vals.map((m) => ( | ||||
|               <span | ||||
|                 key={m} | ||||
|                 style={{ | ||||
|                   padding: '2px 8px', | ||||
|                   borderRadius: '12px', | ||||
|                   background: '#DFCCBC', | ||||
|                   color: '#26201A', | ||||
|                   fontSize: 12, | ||||
|                   lineHeight: '18px', | ||||
|                 }} | ||||
|               > | ||||
|                 {m} | ||||
|               </span> | ||||
|             )) : '—'} | ||||
|           </Box> | ||||
|         ); | ||||
|       }, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|     }, | ||||
|     { | ||||
|       field: 'createdAt', | ||||
|       headerName: 'Created Date', | ||||
|       flex: 1.0, | ||||
|       minWidth: 180, | ||||
|       hide: true, | ||||
|       valueFormatter: (p) => { | ||||
|         const v = p?.value; | ||||
|         return v ? new Date(v).toLocaleString() : '—'; | ||||
|       }, | ||||
|     }, | ||||
|     { field: 'createdBy', headerName: 'Created By', flex: 0.9, minWidth: 160, hide: true }, | ||||
|     { | ||||
|       field: 'updatedAt', | ||||
|       headerName: 'Updated Date', | ||||
|       flex: 1.0, | ||||
|       minWidth: 180, | ||||
|       hide: true, | ||||
|       valueFormatter: (p) => { | ||||
|         const v = p?.value; | ||||
|         return v ? new Date(v).toLocaleString() : '—'; | ||||
|       }, | ||||
|     }, | ||||
|     { field: 'updatedBy', headerName: 'Updated By', flex: 0.9, minWidth: 160, hide: true }, | ||||
|     { field: 'status', headerName: 'Status', flex: 0.7, minWidth: 120 }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         height: 'calc(100vh - 64px - 64px)', | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         gap: 2, | ||||
|       }} | ||||
|     > | ||||
|       <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}> | ||||
|         <Typography color='text.primary' variant="h6">Categories</Typography> | ||||
|  | ||||
|         <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> | ||||
|           <ToggleButtonGroup | ||||
|             value={statusFilter} | ||||
|             exclusive | ||||
|             onChange={(_, v) => v && setStatusFilter(v)} | ||||
|             size="small" | ||||
|           > | ||||
|             <ToggleButton value="Active">Active</ToggleButton> | ||||
|             <ToggleButton value="All">All</ToggleButton> | ||||
|             <ToggleButton value="Inactive">Inactive</ToggleButton> | ||||
|           </ToggleButtonGroup> | ||||
|  | ||||
|           <Button variant="contained" onClick={handleAddClick} className="button-gold"> | ||||
|             Add Category | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ flex: 1, minHeight: 0 }}> | ||||
|         <DataGrid | ||||
|           rows={filteredRows} | ||||
|           columns={columns} | ||||
|           initialState={{ | ||||
|             pagination: { paginationModel: { pageSize } }, | ||||
|             columns: { | ||||
|               columnVisibilityModel: { | ||||
|                 createdAt: false, | ||||
|                 createdBy: false, | ||||
|                 updatedAt: false, | ||||
|                 updatedBy: false, | ||||
|               }, | ||||
|             }, | ||||
|           }} | ||||
|           pageSizeOptions={[pageSize]} | ||||
|           disableColumnMenu | ||||
|           getRowId={(r) => r?._id || r?.id} | ||||
|           sx={{ | ||||
|             height: '100%', | ||||
|             '& .MuiDataGrid-cell, & .MuiDataGrid-columnHeader': { | ||||
|               display: 'flex', | ||||
|               alignItems: 'center', | ||||
|             }, | ||||
|             '& .MuiDataGrid-filler': { | ||||
|               display: 'none', | ||||
|             }, | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ p: 2 }}>No categories found. Try switching the status filter to "All".</Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </Box> | ||||
|  | ||||
|       <Dialog open={open} onClose={() => { setOpen(false); setEditingCategory(null); }} fullWidth> | ||||
|         <DialogTitle>{editingCategory ? 'Edit Category' : 'Add Category'}</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <AddOrEditCategoryForm | ||||
|             initialData={editingCategory} | ||||
|             allTags={allTags} | ||||
|             initialMaterialNames={editingCategory?.materialNames || []} | ||||
|             onAdd={handleFormDone} | ||||
|             onCancel={() => { setOpen(false); setEditingCategory(null); }} | ||||
|             viewOnly={viewOnly} | ||||
|           /> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|  | ||||
|       <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}> | ||||
|         <DialogTitle>Delete Category</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <Box sx={{ display: 'flex', gap: 1, mt: 2, mb: 1 }}> | ||||
|             <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|             <Button color="error" variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button> | ||||
|           </Box> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										361
									
								
								src/private/catalogs/products/AddOrEditProductCollectionForm.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,361 @@ | ||||
| // src/private/furniture/AddOrEditFurnitureVariantForm.jsx | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { Box, Button, TextField, MenuItem, CircularProgress } from '@mui/material'; | ||||
| import FurnitureVariantApi from '../../../api/ProductsApi'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import TagTypeApi from '../../../api/TagTypeApi'; | ||||
| import { useAuth } from '../../../context/AuthContext'; | ||||
|  | ||||
| const DEFAULT_MODEL_ID = '8a23117b-acaf-4d87-b64f-a98e9b414796'; | ||||
|  | ||||
| const TYPE_NAMES = { | ||||
|   category: 'Furniture category', | ||||
|   provider: 'Provider', | ||||
|   color: 'Color', | ||||
|   line: 'Line', | ||||
|   currency: 'Currency', | ||||
|   material: 'Material', | ||||
|   legs: 'Legs', | ||||
|   origin: 'Origin', | ||||
| }; | ||||
|  | ||||
| export default function AddOrEditProductCollectionForm({ initialData, onAdd, onCancel, viewOnly = false }) { | ||||
|   const { user } = useAuth(); | ||||
|   const token = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|  | ||||
|   const variantApi = useMemo(() => new FurnitureVariantApi(token), [token]); | ||||
|   const tagTypeApi = useMemo(() => new TagTypeApi(token), [token]); | ||||
|   const categoriesApi = useMemo(() => new CategoriesApi(token), [token]); | ||||
|  | ||||
|   const [loadingTags, setLoadingTags] = useState(true); | ||||
|   const [typeMap, setTypeMap] = useState({}); | ||||
|   const [options, setOptions] = useState({ | ||||
|     categories: [], | ||||
|     providers: [], | ||||
|     colors: [], | ||||
|     lines: [], | ||||
|     currencies: [], | ||||
|     materials: [], | ||||
|     legs: [], | ||||
|     origins: [], | ||||
|   }); | ||||
|  | ||||
|   const [form, setForm] = useState({ | ||||
|     _Id: '', | ||||
|     id: '', | ||||
|     modelId: DEFAULT_MODEL_ID, | ||||
|     name: '', | ||||
|     color: '', | ||||
|     line: '', | ||||
|     stock: 0, | ||||
|     price: 0, | ||||
|     currency: 'USD', | ||||
|     categoryId: '', | ||||
|     providerId: '', | ||||
|     attributes: { material: '', legs: '', origin: '' }, | ||||
|     status: 'Active', | ||||
|   }); | ||||
|  | ||||
|   const setVal = (path, value) => { | ||||
|     if (path.startsWith('attributes.')) { | ||||
|       const k = path.split('.')[1]; | ||||
|       setForm(prev => ({ ...prev, attributes: { ...prev.attributes, [k]: value } })); | ||||
|     } else { | ||||
|       setForm(prev => ({ ...prev, [path]: value })); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const parsePrice = (p) => { | ||||
|     if (p == null) return 0; | ||||
|     if (typeof p === 'number') return p; | ||||
|     if (typeof p === 'string') return Number(p) || 0; | ||||
|     if (typeof p === 'object' && p.$numberDecimal) return Number(p.$numberDecimal) || 0; | ||||
|     return 0; | ||||
|   }; | ||||
|  | ||||
|   // Cargar TagTypes + Tags | ||||
|   useEffect(() => { | ||||
|     let mounted = true; | ||||
|     (async () => { | ||||
|       try { | ||||
|         const [types, tags] = await Promise.all([tagTypeApi.getAll(), categoriesApi.getAll()]); | ||||
|  | ||||
|         const tmap = {}; | ||||
|         types?.forEach(t => { | ||||
|           if (!t?.typeName || !t?._id) return; | ||||
|           tmap[t.typeName] = t._id; | ||||
|         }); | ||||
|  | ||||
|         const byType = (tname) => { | ||||
|           const tid = tmap[tname]; | ||||
|           if (!tid) return []; | ||||
|           return (tags || []) | ||||
|             .filter(tag => tag?.typeId === tid) | ||||
|             .map(tag => ({ ...tag })); | ||||
|         }; | ||||
|  | ||||
|         if (mounted) { | ||||
|           setTypeMap(tmap); | ||||
|           setOptions({ | ||||
|             categories: byType(TYPE_NAMES.category), | ||||
|             providers: byType(TYPE_NAMES.provider), | ||||
|             colors: byType(TYPE_NAMES.color), | ||||
|             lines: byType(TYPE_NAMES.line), | ||||
|             currencies: byType(TYPE_NAMES.currency), | ||||
|             materials: byType(TYPE_NAMES.material), | ||||
|             legs: byType(TYPE_NAMES.legs), | ||||
|             origins: byType(TYPE_NAMES.origin), | ||||
|           }); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         console.error('Failed loading TagTypes/Tags', err); | ||||
|       } finally { | ||||
|         if (mounted) setLoadingTags(false); | ||||
|       } | ||||
|     })(); | ||||
|     return () => { mounted = false; }; | ||||
|   }, [tagTypeApi, categoriesApi]); | ||||
|  | ||||
|   // Sembrar datos al editar | ||||
|   useEffect(() => { | ||||
|     if (!initialData) return; | ||||
|     setForm({ | ||||
|       _Id: initialData._Id ?? initialData._id ?? '', | ||||
|       id: initialData.id ?? '', | ||||
|       modelId: initialData.modelId ?? DEFAULT_MODEL_ID, | ||||
|       name: initialData.name ?? '', | ||||
|       color: initialData.color ?? '', | ||||
|       line: initialData.line ?? '', | ||||
|       stock: Number(initialData.stock ?? 0), | ||||
|       price: parsePrice(initialData.price), | ||||
|       currency: initialData.currency ?? 'USD', | ||||
|       categoryId: initialData.categoryId ?? '', | ||||
|       providerId: initialData.providerId ?? '', | ||||
|       attributes: { | ||||
|         material: initialData?.attributes?.material ?? '', | ||||
|         legs: initialData?.attributes?.legs ?? '', | ||||
|         origin: initialData?.attributes?.origin ?? '', | ||||
|       }, | ||||
|       status: initialData.status ?? 'Active', | ||||
|     }); | ||||
|   }, [initialData]); | ||||
|  | ||||
|   // Si viene GUID/_id/slug => convertir a tagName | ||||
|   useEffect(() => { | ||||
|     if (loadingTags) return; | ||||
|  | ||||
|     const toTagNameIfNeeded = (value, list) => { | ||||
|       if (!value) return ''; | ||||
|       if (list.some(t => t.tagName === value)) return value; // ya es tagName | ||||
|       const found = list.find(t => t.id === value || t._id === value || t._id?.$oid === value || t.slug === value); | ||||
|       return found?.tagName || value; | ||||
|     }; | ||||
|  | ||||
|     setForm(prev => ({ | ||||
|       ...prev, | ||||
|       categoryId: toTagNameIfNeeded(prev.categoryId, options.categories), | ||||
|       providerId: toTagNameIfNeeded(prev.providerId, options.providers), | ||||
|       color: toTagNameIfNeeded(prev.color, options.colors), | ||||
|       line: toTagNameIfNeeded(prev.line, options.lines), | ||||
|       currency: toTagNameIfNeeded(prev.currency, options.currencies), | ||||
|       attributes: { | ||||
|         ...prev.attributes, | ||||
|         material: toTagNameIfNeeded(prev.attributes?.material, options.materials), | ||||
|         legs: toTagNameIfNeeded(prev.attributes?.legs, options.legs), | ||||
|         origin: toTagNameIfNeeded(prev.attributes?.origin, options.origins), | ||||
|       } | ||||
|     })); | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|   }, [loadingTags, options]); | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       const payload = { | ||||
|         ...form, | ||||
|         stock: Number(form.stock ?? 0), | ||||
|         price: Number(form.price ?? 0), | ||||
|         categoryId: form.categoryId || null,  // enviamos tagName | ||||
|         providerId: form.providerId || null,  // enviamos tagName | ||||
|         attributes: { | ||||
|           material: form.attributes.material || '', | ||||
|           legs: form.attributes.legs || '', | ||||
|           origin: form.attributes.origin || '', | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       const isUpdate = Boolean(form.id || form._Id); | ||||
|       const saved = isUpdate | ||||
|         ? await variantApi.updateVariant(payload) | ||||
|         : await variantApi.createVariant(payload); | ||||
|  | ||||
|       onAdd?.(saved); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|       alert(err?.message || 'Error saving variant'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Box display="flex" flexDirection="column" gap={2}> | ||||
|         {/* Name */} | ||||
|         <TextField | ||||
|           label="Name" | ||||
|           fullWidth | ||||
|           value={form.name} | ||||
|           onChange={(e) => setVal('name', e.target.value)} | ||||
|           sx={{ mt: 1 }} | ||||
|           disabled={viewOnly} | ||||
|         /> | ||||
|  | ||||
|         {/* Category / Provider */} | ||||
|         <TextField | ||||
|           select | ||||
|           label="Category" | ||||
|           fullWidth | ||||
|           value={form.categoryId} | ||||
|           onChange={(e) => setVal('categoryId', e.target.value)} | ||||
|           helperText="Se envía el tagName por ahora" | ||||
|           disabled={viewOnly} | ||||
|         > | ||||
|           {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|           {!loadingTags && options.categories.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|             <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|           ))} | ||||
|         </TextField> | ||||
|         <TextField | ||||
|           select | ||||
|           label="Provider" | ||||
|           fullWidth | ||||
|           value={form.providerId} | ||||
|           onChange={(e) => setVal('providerId', e.target.value)} | ||||
|           disabled={viewOnly} | ||||
|         > | ||||
|           {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|           {!loadingTags && options.providers.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|             <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|           ))} | ||||
|         </TextField> | ||||
|  | ||||
|         <Box display="flex" gap={2}> | ||||
|           {/* Color */} | ||||
|           <Box flex={1}> | ||||
|             <TextField | ||||
|               select | ||||
|               label="Color" | ||||
|               fullWidth | ||||
|               value={form.color} | ||||
|               onChange={(e) => setVal('color', e.target.value)} | ||||
|               disabled={viewOnly} | ||||
|             > | ||||
|               {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|               {!loadingTags && options.colors.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|                 <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|               ))} | ||||
|             </TextField> | ||||
|           </Box> | ||||
|           {/* Line */} | ||||
|           <Box flex={1}> | ||||
|             <TextField | ||||
|               select | ||||
|               label="Line" | ||||
|               fullWidth | ||||
|               value={form.line} | ||||
|               onChange={(e) => setVal('line', e.target.value)} | ||||
|               disabled={viewOnly} | ||||
|             > | ||||
|               {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|               {!loadingTags && options.lines.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|                 <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|               ))} | ||||
|             </TextField> | ||||
|           </Box> | ||||
|           {/* Material */} | ||||
|           <Box flex={1}> | ||||
|             <TextField | ||||
|               select | ||||
|               label="Material" | ||||
|               fullWidth | ||||
|               value={form.attributes.material} | ||||
|               onChange={(e) => setVal('attributes.material', e.target.value)} | ||||
|               disabled={viewOnly} | ||||
|             > | ||||
|               {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|               {!loadingTags && options.materials.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|                 <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|               ))} | ||||
|             </TextField> | ||||
|           </Box> | ||||
|         </Box> | ||||
|  | ||||
|         <Box display="flex" gap={2}> | ||||
|           {/* Price */} | ||||
|           <Box flex={1}> | ||||
|             <TextField label="Price" type="number" fullWidth value={form.price} onChange={(e) => setVal('price', e.target.value)} disabled={viewOnly} /> | ||||
|           </Box> | ||||
|           {/* Stock */} | ||||
|           <Box flex={1}> | ||||
|             <TextField label="Stock" type="number" fullWidth value={form.stock} onChange={(e) => setVal('stock', e.target.value)} disabled={viewOnly} /> | ||||
|           </Box> | ||||
|           {/* Currency */} | ||||
|           <Box flex={1}> | ||||
|             <TextField | ||||
|               select | ||||
|               label="Currency" | ||||
|               fullWidth | ||||
|               value={form.currency} | ||||
|               onChange={(e) => setVal('currency', e.target.value)} | ||||
|               disabled={viewOnly} | ||||
|             > | ||||
|               {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|               {!loadingTags && options.currencies.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|                 <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|               ))} | ||||
|             </TextField> | ||||
|           </Box> | ||||
|         </Box> | ||||
|  | ||||
|         {/* Attributes */} | ||||
|         <TextField | ||||
|           select | ||||
|           label="Legs" | ||||
|           fullWidth | ||||
|           value={form.attributes.legs} | ||||
|           onChange={(e) => setVal('attributes.legs', e.target.value)} | ||||
|           disabled={viewOnly} | ||||
|         > | ||||
|           {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|           {!loadingTags && options.legs.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|             <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|           ))} | ||||
|         </TextField> | ||||
|         <TextField | ||||
|           select | ||||
|           label="Origin" | ||||
|           fullWidth | ||||
|           value={form.attributes.origin} | ||||
|           onChange={(e) => setVal('attributes.origin', e.target.value)} | ||||
|           disabled={viewOnly} | ||||
|         > | ||||
|           {loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando…</MenuItem>} | ||||
|           {!loadingTags && options.origins.filter(t => t.status !== 'Inactive').map(tag => ( | ||||
|             <MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem> | ||||
|           ))} | ||||
|         </TextField> | ||||
|  | ||||
|         {/* Status */} | ||||
|         <TextField select label="Status" fullWidth value={form.status} onChange={(e) => setVal('status', e.target.value)} disabled={viewOnly}> | ||||
|           <MenuItem value="Active">Active</MenuItem> | ||||
|           <MenuItem value="Inactive">Inactive</MenuItem> | ||||
|         </TextField> | ||||
|       </Box> | ||||
|  | ||||
|       <Box display="flex" justifyContent="flex-end" mt={3} gap={1}> | ||||
|         <Button onClick={onCancel} className="button-transparent">{viewOnly ? 'Close' : 'Cancel'}</Button> | ||||
|         {!viewOnly && ( | ||||
|           <Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button> | ||||
|         )} | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										397
									
								
								src/private/catalogs/products/ProductCollections.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,397 @@ | ||||
| import SectionContainer from '../../../components/SectionContainer'; | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { DataGrid } from '@mui/x-data-grid'; | ||||
| import { | ||||
|   Typography, Button, Dialog, DialogTitle, DialogContent, | ||||
|   IconButton, Box, ToggleButtonGroup, ToggleButton | ||||
| } from '@mui/material'; | ||||
| import EditRoundedIcon from '@mui/icons-material/EditRounded'; | ||||
| import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; | ||||
| import AddRoundedIcon from '@mui/icons-material/AddRounded'; | ||||
| import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; | ||||
| import AddOrEditProductCollectionForm from './AddOrEditProductCollectionForm'; | ||||
| import FurnitureVariantApi from '../../../api/ProductsApi'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import TagTypeApi from '../../../api/TagTypeApi'; | ||||
| import { useAuth } from '../../../context/AuthContext'; | ||||
| import useApiToast from '../../../hooks/useApiToast'; | ||||
|  | ||||
| const parsePrice = (p) => { | ||||
|   if (p == null) return 0; | ||||
|   if (typeof p === 'number') return p; | ||||
|   if (typeof p === 'string') return Number(p) || 0; | ||||
|   if (typeof p === 'object' && p.$numberDecimal) return Number(p.$numberDecimal) || 0; | ||||
|   return 0; | ||||
| }; | ||||
|  | ||||
| const TYPE_NAMES = { | ||||
|   category: 'Furniture category', | ||||
|   provider: 'Provider', | ||||
|   color: 'Color', | ||||
|   line: 'Line', | ||||
|   currency: 'Currency', | ||||
|   material: 'Material', | ||||
|   legs: 'Legs', | ||||
|   origin: 'Origin', | ||||
| }; | ||||
|  | ||||
| export default function ProductCollections() { | ||||
|   const { user } = useAuth(); | ||||
|   const token = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|  | ||||
|   const api = useMemo(() => new FurnitureVariantApi(token), [token]); | ||||
|   const tagTypeApi = useMemo(() => new TagTypeApi(token), [token]); | ||||
|   const categoriesApi = useMemo(() => new CategoriesApi(token), [token]); | ||||
|  | ||||
|   const { handleError } = useApiToast(); | ||||
|  | ||||
|   const [rows, setRows] = useState([]); | ||||
|   const [rawRows, setRawRows] = useState([]); | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [editRow, setEditRow] = useState(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   const [statusFilter, setStatusFilter] = useState('All'); | ||||
|  | ||||
|   const [viewOnly, setViewOnly] = useState(false); | ||||
|  | ||||
|   const [confirmOpen, setConfirmOpen] = useState(false); | ||||
|  | ||||
|   // Tags | ||||
|   const [loadingTags, setLoadingTags] = useState(true); | ||||
|   const [typeMap, setTypeMap] = useState({}); | ||||
|   const [byType, setByType] = useState({ | ||||
|     [TYPE_NAMES.category]: [], | ||||
|     [TYPE_NAMES.provider]: [], | ||||
|     [TYPE_NAMES.color]: [], | ||||
|     [TYPE_NAMES.line]: [], | ||||
|     [TYPE_NAMES.currency]: [], | ||||
|     [TYPE_NAMES.material]: [], | ||||
|     [TYPE_NAMES.legs]: [], | ||||
|     [TYPE_NAMES.origin]: [], | ||||
|   }); | ||||
|  | ||||
|   const buildLabelResolver = (typeName) => { | ||||
|     const list = byType[typeName] || []; | ||||
|     return (value) => { | ||||
|       if (!value && value !== 0) return '—'; | ||||
|       if (list.some(t => t.tagName === value)) return value; // ya es tagName | ||||
|       const found = list.find(t => | ||||
|         t.id === value || | ||||
|         t._id === value || | ||||
|         t._id?.$oid === value || | ||||
|         t.slug === value | ||||
|       ); | ||||
|       return found?.tagName || String(value); | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   // Cargar TagTypes + Tags | ||||
|   useEffect(() => { | ||||
|     let mounted = true; | ||||
|     (async () => { | ||||
|       try { | ||||
|         const [types, tags] = await Promise.all([tagTypeApi.getAll(), categoriesApi.getAll()]); | ||||
|         const tmap = {}; | ||||
|         types?.forEach(t => { | ||||
|           if (!t?.typeName || !t?._id) return; | ||||
|           tmap[t.typeName] = t._id; | ||||
|         }); | ||||
|         const group = (tname) => { | ||||
|           const tid = tmap[tname]; | ||||
|           if (!tid) return []; | ||||
|           return (tags || []).filter(tag => tag?.typeId === tid); | ||||
|         }; | ||||
|         if (mounted) { | ||||
|           setTypeMap(tmap); | ||||
|           setByType({ | ||||
|             [TYPE_NAMES.category]: group(TYPE_NAMES.category), | ||||
|             [TYPE_NAMES.provider]: group(TYPE_NAMES.provider), | ||||
|             [TYPE_NAMES.color]: group(TYPE_NAMES.color), | ||||
|             [TYPE_NAMES.line]: group(TYPE_NAMES.line), | ||||
|             [TYPE_NAMES.currency]: group(TYPE_NAMES.currency), | ||||
|             [TYPE_NAMES.material]: group(TYPE_NAMES.material), | ||||
|             [TYPE_NAMES.legs]: group(TYPE_NAMES.legs), | ||||
|             [TYPE_NAMES.origin]: group(TYPE_NAMES.origin), | ||||
|           }); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error('Failed loading TagTypes/Tags', e); | ||||
|       } finally { | ||||
|         if (mounted) setLoadingTags(false); | ||||
|       } | ||||
|     })(); | ||||
|     return () => { mounted = false; }; | ||||
|   }, [tagTypeApi, categoriesApi]); | ||||
|  | ||||
|   // Cargar variants | ||||
|   const load = async () => { | ||||
|     try { | ||||
|       setLoading(true); | ||||
|       const data = await api.getAllVariants(); | ||||
|       const normalized = (data || []).map((r, idx) => ({ | ||||
|         id: r.id || r._id || `row-${idx}`, | ||||
|         _Id: r._id || r._Id || '', | ||||
|         modelId: r.modelId ?? '', | ||||
|         name: r.name ?? '', | ||||
|         categoryId: r.categoryId ?? '', | ||||
|         providerId: r.providerId ?? '', | ||||
|         color: r.color ?? '', | ||||
|         line: r.line ?? '', | ||||
|         stock: Number(r.stock ?? 0), | ||||
|         price: parsePrice(r.price), | ||||
|         currency: r.currency ?? 'USD', | ||||
|         attributes: { | ||||
|           material: r?.attributes?.material ?? '', | ||||
|           legs: r?.attributes?.legs ?? '', | ||||
|           origin: r?.attributes?.origin ?? '', | ||||
|         }, | ||||
|         status: r.status ?? 'Active', | ||||
|         createdAt: r.createdAt ?? null, | ||||
|         createdBy: r.createdBy ?? null, | ||||
|       })); | ||||
|       setRawRows(normalized); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|       handleError(err, 'Error loading product collections'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { load(); /* eslint-disable-next-line */ }, []); | ||||
|   useEffect(() => { | ||||
|     if (statusFilter === 'All') { | ||||
|       setRows(rawRows); | ||||
|     } else { | ||||
|       const want = statusFilter.toLowerCase(); | ||||
|       setRows(rawRows.filter(r => String(r.status ?? 'Active').toLowerCase() === want)); | ||||
|     } | ||||
|   }, [statusFilter, rawRows]); | ||||
|  | ||||
|   const handleDeleteClick = (row) => { | ||||
|     setEditRow(row); | ||||
|     setConfirmOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const confirmDelete = async () => { | ||||
|     try { | ||||
|       if (!editRow?.id) return; | ||||
|       await api.changeStatusVariant({ mongoId: editRow._Id, status: 'Inactive' }); | ||||
|       await load(); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|     } finally { | ||||
|       setConfirmOpen(false); | ||||
|       setEditRow(null); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const columns = [ | ||||
|     { | ||||
|       field: 'actions', | ||||
|       headerName: '', | ||||
|       width: 190, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|       disableExport: true, | ||||
|       renderCell: (params) => ( | ||||
|         <Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#DFCCBC', | ||||
|               color: '#26201A', | ||||
|               '&:hover': { backgroundColor: '#C2B2A4' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { setEditRow(params.row); setViewOnly(false); setOpen(true); }} | ||||
|           > | ||||
|             <EditRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#E3F2FD', | ||||
|               color: '#1565C0', | ||||
|               '&:hover': { backgroundColor: '#BBDEFB' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { setEditRow(params.row); setOpen(true); setViewOnly(true); }} | ||||
|           > | ||||
|             <VisibilityRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#FBE9E7', | ||||
|               color: '#C62828', | ||||
|               '&:hover': { backgroundColor: '#EF9A9A' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { setViewOnly(false); handleDeleteClick(params?.row); }} | ||||
|           > | ||||
|             <DeleteRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|         </Box> | ||||
|       ), | ||||
|     }, | ||||
|     { field: 'name', headerName: 'Name', flex: 1, minWidth: 160 }, | ||||
|     { field: 'categoryId', headerName: 'Category', flex: 1, minWidth: 160 }, | ||||
|     { field: 'providerId', headerName: 'Provider', flex: 1, minWidth: 160 }, | ||||
|     { field: 'color', headerName: 'Color', flex: 1, minWidth: 160 }, | ||||
|     { field: 'line', headerName: 'Line', flex: 1, minWidth: 160 }, | ||||
|     { | ||||
|       field: 'price', | ||||
|       headerName: 'Price', | ||||
|       flex: 1, | ||||
|       minWidth: 160, | ||||
|       type: 'number', | ||||
|       valueGetter: (p) => parsePrice(p?.row?.price), | ||||
|       renderCell: (p) => { | ||||
|         const val = parsePrice(p?.row?.price); | ||||
|         const currency = p?.row?.currency || 'USD'; | ||||
|         try { | ||||
|           return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(val); | ||||
|         } catch { | ||||
|           return `${currency} ${val.toFixed(2)}`; | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { field: 'currency', headerName: 'Currency', flex: 1, minWidth: 160 }, | ||||
|     { field: 'stock', headerName: 'Stock', flex: 1, minWidth: 160 }, | ||||
|     { | ||||
|       field: 'attributes', | ||||
|       headerName: 'Attributes', | ||||
|       minWidth: 300, | ||||
|       flex: 1.5, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|       renderCell: (params) => { | ||||
|         const a = params?.row?.attributes || {}; | ||||
|         const chips = Object.entries(a).filter(([, v]) => !!v); | ||||
|         return ( | ||||
|           <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> | ||||
|             {chips.map(([key, value]) => ( | ||||
|               <Box | ||||
|                 key={key} | ||||
|                 component="span" | ||||
|                 sx={{ | ||||
|                   px: 1.5, | ||||
|                   py: 0.5, | ||||
|                   borderRadius: '12px', | ||||
|                   backgroundColor: '#DFCCBC', | ||||
|                   fontSize: 12, | ||||
|                   color: '#26201A', | ||||
|                   lineHeight: '18px', | ||||
|                 }} | ||||
|               > | ||||
|                 {key}: {value} | ||||
|               </Box> | ||||
|             ))} | ||||
|           </Box> | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|     { field: 'status', headerName: 'Status', flex: 1, minWidth: 160 } | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         height: 'calc(100vh - 64px - 64px)', | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         gap: 2, | ||||
|       }} | ||||
|     > | ||||
|       <Box display="flex" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={2}> | ||||
|         <Typography color='text.primary' variant="h6">Product Collection</Typography> | ||||
|         <Box display="flex" alignItems="center" gap={2}> | ||||
|           <ToggleButtonGroup | ||||
|             value={statusFilter} | ||||
|             exclusive | ||||
|             onChange={(_, v) => v && setStatusFilter(v)} | ||||
|             size="small" | ||||
|           > | ||||
|             <ToggleButton value="Active">Active</ToggleButton> | ||||
|             <ToggleButton value="All">All</ToggleButton> | ||||
|             <ToggleButton value="Inactive">Inactive</ToggleButton> | ||||
|           </ToggleButtonGroup> | ||||
|           <Button variant="contained" className="button-gold" startIcon={<AddRoundedIcon />} onClick={() => { setEditRow(null); setViewOnly(false); setOpen(true); }}> | ||||
|             Add Product Collection | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ flex: 1, minHeight: 0 }}> | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           disableRowSelectionOnClick | ||||
|           loading={loading || loadingTags} | ||||
|           pageSizeOptions={[50, 100, 200]} | ||||
|           initialState={{ | ||||
|             pagination: { paginationModel: { pageSize: 50 } }, | ||||
|             columns: { columnVisibilityModel: { id: false, _Id: false } }, | ||||
|           }} | ||||
|           getRowHeight={() => 'auto'} | ||||
|           columnBuffer={0} | ||||
|           sx={{ | ||||
|             height: '100%', | ||||
|             '& .MuiDataGrid-cell, & .MuiDataGrid-columnHeader': { | ||||
|               display: 'flex', | ||||
|               alignItems: 'center', | ||||
|             }, | ||||
|             '& .MuiDataGrid-filler': { | ||||
|               display: 'none', | ||||
|             }, | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ p: 2 }}>No product collection found. Try switching the status filter to "All".</Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </Box> | ||||
|  | ||||
|       <Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth> | ||||
|         <DialogTitle> | ||||
|           {viewOnly | ||||
|             ? 'View Product Collection' | ||||
|             : editRow | ||||
|               ? 'Edit Product Collection' | ||||
|               : 'Add Product Collection'} | ||||
|         </DialogTitle> | ||||
|         <DialogContent> | ||||
|           <AddOrEditProductCollectionForm | ||||
|             initialData={editRow} | ||||
|             viewOnly={viewOnly} | ||||
|             onAdd={(saved) => { | ||||
|               setOpen(false); | ||||
|               if (editRow) { | ||||
|                 setRawRows(prev => prev.map(r => (r.id === editRow.id ? { ...saved } : r))); | ||||
|               } else { | ||||
|                 setRawRows(prev => [{ ...saved, id: saved.id || saved._id || `row-${Date.now()}` }, ...prev]); | ||||
|               } | ||||
|             }} | ||||
|             onCancel={() => setOpen(false)} | ||||
|           /> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|  | ||||
|       <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}> | ||||
|         <DialogTitle>Delete Product Collection</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <Box sx={{ display: 'flex', gap: 1, mt: 2, mb: 1 }}> | ||||
|             <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|             <Button color="error" variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button> | ||||
|           </Box> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,64 +0,0 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { Box, Button, TextField, Typography, Paper } from '@mui/material'; | ||||
|  | ||||
| export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel }) { | ||||
|     const [category, setCategory] = useState({ | ||||
|         name: '', | ||||
|         description: '' | ||||
|     }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (initialData) { | ||||
|             setCategory(initialData); | ||||
|         } else { | ||||
|             setCategory({ name: '', description: '' }); | ||||
|         } | ||||
|     }, [initialData]); | ||||
|  | ||||
|     const handleChange = (e) => { | ||||
|         const { name, value } = e.target; | ||||
|         setCategory((prev) => ({ ...prev, [name]: value })); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmit = () => { | ||||
|         if (onAdd) { | ||||
|             onAdd(category); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ px: 2, py: 3 }}> | ||||
|             <Paper elevation={0} sx={{ p: 3, bgcolor: '#f9f9f9', borderRadius: 2 }}> | ||||
|                 <Typography variant="h6" gutterBottom> | ||||
|                     Category Details | ||||
|                 </Typography> | ||||
|                 <TextField | ||||
|                     fullWidth | ||||
|                     label="Name" | ||||
|                     name="name" | ||||
|                     value={category.name} | ||||
|                     onChange={handleChange} | ||||
|                     margin="normal" | ||||
|                 /> | ||||
|                 <TextField | ||||
|                     fullWidth | ||||
|                     label="Description" | ||||
|                     name="description" | ||||
|                     value={category.description} | ||||
|                     onChange={handleChange} | ||||
|                     margin="normal" | ||||
|                     multiline | ||||
|                     rows={4} | ||||
|                 /> | ||||
|                 <Box display="flex" justifyContent="flex-end" gap={1} mt={3}> | ||||
|                     <Button onClick={onCancel} className="button-transparent"> | ||||
|                         Cancel | ||||
|                     </Button> | ||||
|                     <Button variant="contained" onClick={handleSubmit} className="button-gold"> | ||||
|                         Save | ||||
|                     </Button> | ||||
|                 </Box> | ||||
|             </Paper> | ||||
|         </Box> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										159
									
								
								src/private/dashboard/Dashboard.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,159 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Grid, Paper, Typography, Divider } from '@mui/material'; | ||||
| import { BarChart, LineChart, PieChart } from '@mui/x-charts'; | ||||
|  | ||||
| const paperSx = { | ||||
|   p: 2, | ||||
|   borderRadius: 2, | ||||
|   boxShadow: '0 2px 8px rgba(0,0,0,0.06)', | ||||
|   backgroundColor: '#fff', | ||||
| }; | ||||
|  | ||||
| const brand = { | ||||
|   primary: '#40120E',   // espresso | ||||
|   primary70: '#6A3A34', // lighter espresso | ||||
|   sand: '#DFCCBC',      // light sand | ||||
|   sand70: '#CDB7A4',    // mid sand | ||||
|   sand50: '#BCA693',    // darker sand | ||||
|   text: '#40120E', | ||||
|   subtle: 'rgba(0,0,0,0.38)', | ||||
|   divider: 'rgba(0,0,0,0.08)', | ||||
| }; | ||||
|  | ||||
| export default function Dashboard() { | ||||
|   const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; | ||||
|   const revenue = [42, 55, 48, 61, 70, 78]; // $k | ||||
|   const orders = [180, 210, 195, 240, 260, 300]; | ||||
|  | ||||
|   const categories = [ | ||||
|     { label: 'Furniture', value: 38 }, | ||||
|     { label: 'Lighting', value: 18 }, | ||||
|     { label: 'Textiles', value: 14 }, | ||||
|     { label: 'Decorative', value: 12 }, | ||||
|     { label: 'Kitchen & Dining', value: 10 }, | ||||
|     { label: 'Outdoor', value: 8 }, | ||||
|   ]; | ||||
|  | ||||
|   const topProducts = [ | ||||
|     { label: 'Sofa Roma', value: 24 }, | ||||
|     { label: 'Lamp Venezia', value: 18 }, | ||||
|     { label: 'Rug Milano', value: 15 }, | ||||
|     { label: 'Table Firenze', value: 12 }, | ||||
|     { label: 'Chair Torino', value: 9 }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Grid container spacing={2} sx={{ mb: 2 }} justifyContent="space-between"> | ||||
|         <Grid item xs={12} md={3}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="subtitle2" sx={{ color: brand.subtle }}>Revenue (Last 30d)</Typography> | ||||
|             <Typography variant="h4" sx={{ color: brand.text }}>$ 312k</Typography> | ||||
|             <Typography variant="caption" sx={{ color: '#2e7d32' }}>+8.4% vs prev.</Typography> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={3}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="subtitle2" sx={{ color: brand.subtle }}>Orders</Typography> | ||||
|             <Typography variant="h4" sx={{ color: brand.text }}>1,385</Typography> | ||||
|             <Typography variant="caption" sx={{ color: '#2e7d32' }}>+6.1% vs prev.</Typography> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={3}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="subtitle2" sx={{ color: brand.subtle }}>Avg. Order Value</Typography> | ||||
|             <Typography variant="h4" sx={{ color: brand.text }}>$ 225</Typography> | ||||
|             <Typography variant="caption" sx={{ color: brand.subtle }}>stable</Typography> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={3}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="subtitle2" sx={{ color: brand.subtle }}>Returning Customers</Typography> | ||||
|             <Typography variant="h4" sx={{ color: brand.text }}>42%</Typography> | ||||
|             <Typography variant="caption" sx={{ color: '#2e7d32' }}>+2.0 pts</Typography> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|  | ||||
|       <Grid container spacing={2} sx={{ mt: 4 }} justifyContent="space-between"> | ||||
|         <Grid item xs={12} md={8}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="h6" sx={{ mb: 1, color: brand.text }}>Revenue & Orders</Typography> | ||||
|             <Divider sx={{ mb: 2, borderColor: brand.divider }} /> | ||||
|             <LineChart | ||||
|               xAxis={[{ scaleType: 'point', data: months }]} | ||||
|               series={[ | ||||
|                 { data: revenue, label: 'Revenue ($k)', curve: 'catmullRom', color: brand.primary }, | ||||
|                 { data: orders, label: 'Orders', curve: 'catmullRom', yAxisKey: 'right', color: brand.sand50 }, | ||||
|               ]} | ||||
|               yAxis={[ | ||||
|                 { tickLabelStyle: { fill: brand.subtle } }, | ||||
|                 { id: 'right', position: 'right', tickLabelStyle: { fill: brand.subtle } }, | ||||
|               ]} | ||||
|               height={300} | ||||
|               // Optional: light grid color | ||||
|               grid={{ horizontal: true, vertical: false }} | ||||
|               sx={{ | ||||
|                 '& .MuiChartsAxis-line': { stroke: brand.divider }, | ||||
|                 '& .MuiChartsAxis-tick': { stroke: brand.divider }, | ||||
|               }} | ||||
|             /> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="h6" sx={{ mb: 1, color: brand.text }}>Sales by Category</Typography> | ||||
|             <Divider sx={{ mb: 2, borderColor: brand.divider }} /> | ||||
|             <PieChart | ||||
|               series={[ | ||||
|                 { | ||||
|                   data: categories.map((c, i) => ({ | ||||
|                     id: i, | ||||
|                     value: c.value, | ||||
|                     label: c.label, | ||||
|                     color: [ | ||||
|                       brand.primary, | ||||
|                       brand.primary70, | ||||
|                       brand.sand50, | ||||
|                       brand.sand70, | ||||
|                       '#8E6E5E', // complementary brown | ||||
|                       '#A68979', // warm accent | ||||
|                     ][i], | ||||
|                   })), | ||||
|                   innerRadius: 30, | ||||
|                   paddingAngle: 2, | ||||
|                 }, | ||||
|               ]} | ||||
|               height={300} | ||||
|             /> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12}> | ||||
|           <Paper sx={paperSx}> | ||||
|             <Typography variant="h6" sx={{ mb: 1, color: brand.text }}>Top Products</Typography> | ||||
|             <Divider sx={{ mb: 2, borderColor: brand.divider }} /> | ||||
|             <BarChart | ||||
|               xAxis={[{ scaleType: 'band', data: topProducts.map(p => p.label) }]} | ||||
|               series={[ | ||||
|                 { | ||||
|                   data: topProducts.map(p => p.value), | ||||
|                   label: 'Units', | ||||
|                   color: brand.primary, | ||||
|                 }, | ||||
|               ]} | ||||
|               height={300} | ||||
|               grid={{ horizontal: true, vertical: false }} | ||||
|               sx={{ | ||||
|                 '& .MuiChartsAxis-line': { stroke: brand.divider }, | ||||
|                 '& .MuiChartsAxis-tick': { stroke: brand.divider }, | ||||
|                 '& .MuiChartsLegend-root': { display: 'none' }, | ||||
|               }} | ||||
|             /> | ||||
|           </Paper> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										164
									
								
								src/private/users/AddOrEditUserForm.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,164 @@ | ||||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| import { Box, Button, TextField, MenuItem } from '@mui/material'; | ||||
| import UserApi from '../../api/userApi'; | ||||
| import { useAuth } from '../../context/AuthContext'; | ||||
|  | ||||
| export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|   const { user } = useAuth(); | ||||
|   const thalosToken = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|   const api = useMemo(() => (thalosToken ? new UserApi(thalosToken) : null), [thalosToken]); | ||||
|  | ||||
|   const [formData, setFormData] = useState({ | ||||
|     _Id: '', | ||||
|     email: '', | ||||
|     name: '', | ||||
|     middleName: '', | ||||
|     lastName: '', | ||||
|     tenantId: '', | ||||
|     roleId: '', | ||||
|     status: 'Active', | ||||
|     sendInvitation: true, | ||||
|   }); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (initialData) { | ||||
|       setFormData({ | ||||
|         _Id: initialData._id || initialData._Id || '', | ||||
|         email: initialData.email ?? '', | ||||
|         name: initialData.name ?? '', | ||||
|         middleName: initialData.middleName ?? '', | ||||
|         lastName: initialData.lastName ?? '', | ||||
|         tenantId: initialData.tenantId ?? '', | ||||
|         roleId: initialData.roleId ?? '', | ||||
|         status: initialData.status ?? 'Active', | ||||
|         sendInvitation: true, | ||||
|       }); | ||||
|     } else { | ||||
|       setFormData({ | ||||
|         _Id: '', | ||||
|         email: '', | ||||
|         name: '', | ||||
|         middleName: '', | ||||
|         lastName: '', | ||||
|         tenantId: '6894f9ddfb7072bdfc881613', | ||||
|         roleId: '68407642ec46a0e6fe1e8ec9', | ||||
|         status: 'Active', | ||||
|         sendInvitation: true, | ||||
|       }); | ||||
|     } | ||||
|   }, [initialData]); | ||||
|  | ||||
|   const handleChange = (e) => { | ||||
|     const { name, value } = e.target; | ||||
|       setFormData(prev => ({ ...prev, [name]: value })); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       if (!api) throw new Error('Missing Thalos token'); | ||||
|  | ||||
|       if (initialData) { | ||||
|         // UPDATE (PUT /User/Update) — API requires _Id, remove Id, displayName, tenantId | ||||
|         const payload = { | ||||
|           _Id: formData._Id, | ||||
|           email: formData.email, | ||||
|           name: formData.name, | ||||
|           middleName: formData.middleName, | ||||
|           lastName: formData.lastName, | ||||
|           tenantId: formData.tenantId, | ||||
|           roleId: formData.roleId, | ||||
|           status: formData.status || 'Active', | ||||
|         }; | ||||
|         await api.updateUser(payload); | ||||
|       } else { | ||||
|         // CREATE (POST /User/Create) | ||||
|         const payload = { | ||||
|           email: formData.email, | ||||
|           name: formData.name, | ||||
|           middleName: formData.middleName, | ||||
|           lastName: formData.lastName, | ||||
|           roleId: formData.roleId, | ||||
|           tenantId: formData.tenantId, | ||||
|           sendInvitation: !!formData.sendInvitation, | ||||
|         }; | ||||
|         await api.createUser(payload); | ||||
|       } | ||||
|  | ||||
|       onAdd?.(); | ||||
|     } catch (error) { | ||||
|       console.error('Error submitting form:', error); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ py: 2 }}> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Email" | ||||
|         name="email" | ||||
|         value={formData.email} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth  | ||||
|         label="Name" | ||||
|         name="name" | ||||
|         value={formData.name} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Middle Name" | ||||
|         name="middleName" | ||||
|         value={formData.middleName} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Last Name" | ||||
|         name="lastName" | ||||
|         value={formData.lastName} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Tenant Id" | ||||
|         name="tenantId" | ||||
|         value={formData.tenantId} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|         disabled={!initialData} | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Role Id" | ||||
|         name="roleId" | ||||
|         value={formData.roleId} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|         disabled={!initialData} | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         select | ||||
|         label="Status" | ||||
|         name="status" | ||||
|         value={formData.status} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|       > | ||||
|         <MenuItem value="Active">Active</MenuItem> | ||||
|         <MenuItem value="Inactive">Inactive</MenuItem> | ||||
|       </TextField> | ||||
|  | ||||
|       <Box display="flex" justifyContent="flex-end" mt={3} gap={1}> | ||||
|         <Button onClick={onCancel} className="button-transparent">Cancel</Button> | ||||
|         <Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										260
									
								
								src/private/users/UserManagement.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,260 @@ | ||||
| import SectionContainer from '../../components/SectionContainer'; | ||||
| import { useEffect, useState, useRef } from 'react'; | ||||
| import { DataGrid } from '@mui/x-data-grid'; | ||||
| import { Typography, Button, Dialog, DialogTitle, DialogContent, IconButton, Box } from '@mui/material'; | ||||
| import EditRoundedIcon from '@mui/icons-material/EditRounded'; | ||||
| import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; | ||||
| import AddOrEditUserForm from './AddOrEditUserForm'; | ||||
| import UserApi from '../../api/userApi'; | ||||
| import { useAuth } from '../../context/AuthContext'; | ||||
| import useApiToast from '../../hooks/useApiToast'; | ||||
| import '../../App.css'; | ||||
|  | ||||
| const columnsBase = [ | ||||
|     { field: 'email', headerName: 'Email', width: 260 }, | ||||
|     { field: 'name', headerName: 'Name', width: 140 }, | ||||
|     { field: 'middleName', headerName: 'Middle Name', width: 140 }, | ||||
|     { field: 'lastName', headerName: 'Last Name', width: 160 }, | ||||
|     { field: 'displayName', headerName: 'Display Name', width: 180, valueGetter: (params) => params?.row?.displayName ?? '—' }, | ||||
|     { field: 'tenantId', headerName: 'Tenant Id', width: 240, valueGetter: (params) => params?.row?.tenantId ?? '—' }, | ||||
|     { field: 'roleId', headerName: 'Role Id', width: 240, valueGetter: (params) => params?.row?.roleId ?? '—' }, | ||||
|     { | ||||
|         field: 'lastLogIn', | ||||
|         headerName: 'Last Login', | ||||
|         width: 180, | ||||
|         valueFormatter: (params) => { | ||||
|             const date = params?.value; | ||||
|             return date ? new Date(date).toLocaleString() : '—'; | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|         field: 'lastLogOut', | ||||
|         headerName: 'Last Logout', | ||||
|         width: 180, | ||||
|         valueFormatter: (params) => { | ||||
|             const date = params?.value; | ||||
|             return date ? new Date(date).toLocaleString() : '—'; | ||||
|         } | ||||
|     }, | ||||
|     { field: 'status', headerName: 'Status', width: 120 }, | ||||
|     { | ||||
|         field: 'createdAt', | ||||
|         headerName: 'Created At', | ||||
|         width: 180, | ||||
|         valueFormatter: (params) => { | ||||
|             const date = params?.value; | ||||
|             return date ? new Date(date).toLocaleString() : '—'; | ||||
|         } | ||||
|     }, | ||||
|     { field: 'createdBy', headerName: 'Created By', width: 160, valueGetter: (p) => p?.row?.createdBy ?? '—' }, | ||||
|     { | ||||
|         field: 'updatedAt', | ||||
|         headerName: 'Updated At', | ||||
|         width: 180, | ||||
|         valueFormatter: (params) => { | ||||
|             const date = params?.value; | ||||
|             return date ? new Date(date).toLocaleString() : '—'; | ||||
|         } | ||||
|     }, | ||||
|     { field: 'updatedBy', headerName: 'Updated By', width: 160, valueGetter: (p) => p?.row?.updatedBy ?? '—' }, | ||||
| ]; | ||||
|  | ||||
| export default function UserManagement() { | ||||
|     const { user } = useAuth(); | ||||
|     const thalosToken = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|     const apiRef = useRef(null); | ||||
|  | ||||
|     const [rows, setRows] = useState([]); | ||||
|     const [open, setOpen] = useState(false); | ||||
|     const [editingData, setEditingData] = useState(null); | ||||
|     const [confirmOpen, setConfirmOpen] = useState(false); | ||||
|     const [rowToDelete, setRowToDelete] = useState(null); | ||||
|     const { handleError } = useApiToast(); | ||||
|  | ||||
|     const hasLoaded = useRef(false); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (thalosToken) { | ||||
|             apiRef.current = new UserApi(thalosToken); | ||||
|         } | ||||
|     }, [thalosToken]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (!hasLoaded.current) { | ||||
|             loadData(); | ||||
|             hasLoaded.current = true; | ||||
|         } | ||||
|     }, []); | ||||
|  | ||||
|     const handleEditClick = (params) => { | ||||
|         if (!params || !params.row) return; | ||||
|         const r = params.row; | ||||
|         const normalized = { | ||||
|             _id: r._id || r._Id || '', | ||||
|             id: r.id || r.Id || '', | ||||
|             email: r.email ?? '', | ||||
|             name: r.name ?? '', | ||||
|             middleName: r.middleName ?? '', | ||||
|             lastName: r.lastName ?? '', | ||||
|             displayName: r.displayName ?? '', | ||||
|             tenantId: r.tenantId ?? '', | ||||
|             roleId: r.roleId ?? '', | ||||
|             status: r.status ?? 'Active', | ||||
|             companies: Array.isArray(r.companies) ? r.companies : [], | ||||
|             projects: Array.isArray(r.projects) ? r.projects : [], | ||||
|         }; | ||||
|         setEditingData(normalized); | ||||
|         setOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleDeleteClick = (row) => { | ||||
|         if (!row) return; | ||||
|         setRowToDelete(row); | ||||
|         setConfirmOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleConfirmDelete = async () => { | ||||
|         try { | ||||
|             if (!apiRef.current || !rowToDelete?._id) throw new Error('Missing API or user id'); | ||||
|  | ||||
|             const payload = { | ||||
|                 _Id: rowToDelete._id || rowToDelete._Id, | ||||
|                 email: rowToDelete.email ?? '', | ||||
|                 name: rowToDelete.name ?? '', | ||||
|                 middleName: rowToDelete.middleName ?? '', | ||||
|                 lastName: rowToDelete.lastName ?? '', | ||||
|                 roleId: '68407642ec46a0e6fe1e8ec9', | ||||
|                 tenantId: '6894f9ddfb7072bdfc881613', | ||||
|                 status: 'Inactive', | ||||
|             }; | ||||
|  | ||||
|             await apiRef.current.updateUser(payload); | ||||
|             await loadData(); | ||||
|         } catch (error) { | ||||
|             console.error('Delete failed:', error); | ||||
|         } finally { | ||||
|             setConfirmOpen(false); | ||||
|             setRowToDelete(null); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const loadData = async () => { | ||||
|         try { | ||||
|             if (!apiRef.current) throw new Error('Missing Thalos token or API not initialized'); | ||||
|             const data = await apiRef.current.getAllUsers(); | ||||
|             const safeData = Array.isArray(data) ? data : []; | ||||
|             setRows(safeData); | ||||
|         } catch (error) { | ||||
|             console.error('Error loading data:', error); | ||||
|             handleError(error, 'Failed to load users'); | ||||
|             setRows([]); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const columns = [ | ||||
|         { | ||||
|             field: 'actions', | ||||
|             headerName: '', | ||||
|             width: 130, | ||||
|             renderCell: (params) => ( | ||||
|                 <Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}> | ||||
|                     <IconButton | ||||
|                         size="small" | ||||
|                         sx={{ | ||||
|                             backgroundColor: '#DFCCBC', | ||||
|                             color: '#26201A', | ||||
|                             '&:hover': { backgroundColor: '#C2B2A4' }, | ||||
|                             borderRadius: 2, | ||||
|                             p: 1, | ||||
|                         }} | ||||
|                         onClick={() => handleEditClick(params)} | ||||
|                     > | ||||
|                         <EditRoundedIcon fontSize="small" /> | ||||
|                     </IconButton> | ||||
|                     <IconButton | ||||
|                         size="small" | ||||
|                         sx={{ | ||||
|                             backgroundColor: '#FBE9E7', | ||||
|                             color: '#C62828', | ||||
|                             '&:hover': { backgroundColor: '#EF9A9A' }, | ||||
|                             borderRadius: 2, | ||||
|                             p: 1, | ||||
|                         }} | ||||
|                         onClick={() => handleDeleteClick(params?.row)} | ||||
|                     > | ||||
|                         <DeleteRoundedIcon fontSize="small" /> | ||||
|                     </IconButton> | ||||
|                 </Box> | ||||
|             ) | ||||
|         }, | ||||
|         ...columnsBase, | ||||
|     ]; | ||||
|  | ||||
|     return ( | ||||
|         <SectionContainer sx={{ width: '100%' }}> | ||||
|             <Dialog open={open} onClose={() => { setOpen(false); setEditingData(null); }} maxWidth="md" fullWidth> | ||||
|                 <DialogTitle>{editingData ? 'Edit User' : 'Add User'}</DialogTitle> | ||||
|                 <DialogContent> | ||||
|                     <AddOrEditUserForm | ||||
|                         key={editingData?._id || editingData?.id || (open ? 'editing' : 'new')} | ||||
|                         onAdd={async () => { | ||||
|                             await loadData(); | ||||
|                             setOpen(false); | ||||
|                             setEditingData(null); | ||||
|                         }} | ||||
|                         initialData={editingData} | ||||
|                         onCancel={() => { | ||||
|                             setOpen(false); | ||||
|                             setEditingData(null); | ||||
|                         }} | ||||
|                     /> | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
|  | ||||
|             <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}> | ||||
|                 <DialogTitle>Confirm Delete</DialogTitle> | ||||
|                 <DialogContent> | ||||
|                     <Typography> | ||||
|                         Are you sure you want to delete <strong>{rowToDelete?.email || rowToDelete?.name}</strong>? | ||||
|                     </Typography> | ||||
|                     <Box mt={2} display="flex" justifyContent="flex-end" gap={1}> | ||||
|                         <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|                         <Button variant="contained" onClick={handleConfirmDelete} className="button-gold">Delete</Button> | ||||
|                     </Box> | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
|  | ||||
|             <Box mt={2} sx={{ width: '100%', overflowX: 'auto' }}> | ||||
|                 <Box sx={{ width: '100%', overflowX: 'auto' }}> | ||||
|                     <DataGrid | ||||
|                         rows={rows} | ||||
|                         columns={columns} | ||||
|                         pageSize={5} | ||||
|                         rowsPerPageOptions={[5]} | ||||
|                         getRowSpacing={() => ({ top: 4, bottom: 4 })} | ||||
|                         getRowId={(row) => row._id || row.id || row.email} | ||||
|                         autoHeight | ||||
|                         disableColumnMenu | ||||
|                         getRowHeight={() => 'auto'} | ||||
|                         sx={{ | ||||
|                             '& .MuiDataGrid-cell': { | ||||
|                                 display: 'flex', | ||||
|                                 alignItems: 'center', | ||||
|                             }, | ||||
|                             '& .MuiDataGrid-columnHeader': { | ||||
|                                 display: 'flex', | ||||
|                                 alignItems: 'center', | ||||
|                             } | ||||
|                         }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|  | ||||
|                 <Box display="flex" justifyContent="flex-end" mt={2}> | ||||
|                     <Button variant="contained" className="button-gold" onClick={() => setOpen(true)} > | ||||
|                         Add User | ||||
|                     </Button> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </SectionContainer> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										80
									
								
								src/public/mongo/AddOrEditAdminForm.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { Box, Button, TextField, MenuItem } from '@mui/material'; | ||||
| import { createExternalData, updateExternalData } from '../../api/mongo/actions'; | ||||
|  | ||||
| export default function AddOrEditAdminForm({ onAdd, initialData, onCancel }) { | ||||
|     const [formData, setFormData] = useState({ | ||||
|         name: '', | ||||
|         description: '', | ||||
|         status: 'Active' | ||||
|     }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (initialData) { | ||||
|             setFormData({ ...initialData }); | ||||
|         } else { | ||||
|             setFormData({ | ||||
|                 name: '', | ||||
|                 description: '', | ||||
|                 status: 'Active' | ||||
|             }); | ||||
|         } | ||||
|     }, [initialData]); | ||||
|  | ||||
|     const handleChange = (e) => { | ||||
|         const { name, value } = e.target; | ||||
|         setFormData(prev => ({ ...prev, [name]: value })); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmit = async () => { | ||||
|         try { | ||||
|             if (initialData) { | ||||
|                 await updateExternalData(formData); | ||||
|             } else { | ||||
|                 await createExternalData(formData); | ||||
|             } | ||||
|             if (onAdd) onAdd(); | ||||
|         } catch (error) { | ||||
|             console.error('Error submitting form:', error); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|  | ||||
|         <Box sx={{ py: 2 }}> | ||||
|             <TextField | ||||
|                 fullWidth | ||||
|                 label="Name" | ||||
|                 name="name" | ||||
|                 value={formData.name} | ||||
|                 onChange={handleChange} | ||||
|                 margin="normal" | ||||
|             /> | ||||
|             <TextField | ||||
|                 fullWidth | ||||
|                 label="Description" | ||||
|                 name="description" | ||||
|                 value={formData.description} | ||||
|                 onChange={handleChange} | ||||
|                 margin="normal" | ||||
|             /> | ||||
|             <TextField | ||||
|                 fullWidth | ||||
|                 select | ||||
|                 label="Status" | ||||
|                 name="status" | ||||
|                 value={formData.status} | ||||
|                 onChange={handleChange} | ||||
|                 margin="normal" | ||||
|             > | ||||
|                 <MenuItem value="Active">Active</MenuItem> | ||||
|                 <MenuItem value="Inactive">Inactive</MenuItem> | ||||
|             </TextField> | ||||
|             <Box display="flex" justifyContent="flex-end" mt={3} gap={1}> | ||||
|                 <Button onClick={onCancel} className="button-transparent">Cancel</Button> | ||||
|                 <Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button> | ||||
|             </Box> | ||||
|  | ||||
|         </Box> | ||||
|     ); | ||||
| } | ||||
| @@ -1,44 +1,58 @@ | ||||
| import SectionContainer from '../../components/SectionContainer.jsx'; | ||||
| import { useState } from 'react'; | ||||
| import SectionContainer from '../../components/SectionContainer'; | ||||
| import { useEffect, useState, useRef } from 'react'; | ||||
| import { DataGrid } from '@mui/x-data-grid'; | ||||
| import { Typography, Button, Dialog, DialogTitle, DialogContent, IconButton, Box } from '@mui/material'; | ||||
| import AddOrEditCategoryForm from './AddOrEditCategoryForm.jsx'; | ||||
| 
 | ||||
| import EditRoundedIcon from '@mui/icons-material/EditRounded'; | ||||
| import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; | ||||
| import '../../App.css'; | ||||
| import AddOrEditAdminForm from './AddOrEditAdminForm'; | ||||
| import { getExternalData, deleteExternalData } from '../../api/mongo/actions'; | ||||
| import useApiToast from '../../hooks/useApiToast'; | ||||
| 
 | ||||
| const columnsBase = [ | ||||
|     { field: 'name', headerName: 'Name', flex: 1 }, | ||||
|     { field: 'description', headerName: 'Description', flex: 2 } | ||||
|     { field: 'name', headerName: 'Name', flex: 2 }, | ||||
|     { field: 'description', headerName: 'Description', flex: 2 }, | ||||
|     { field: 'status', headerName: 'Status', width: 120 }, | ||||
|     { | ||||
|         field: 'createdAt', | ||||
|         headerName: 'Created At', | ||||
|         width: 120, | ||||
|         valueFormatter: (params) => { | ||||
|             const date = params?.value; | ||||
|             return date ? new Date(date).toLocaleString() : '—'; | ||||
|         } | ||||
|     }, | ||||
|     { field: 'createdBy', headerName: 'Created By', flex: 1 }, | ||||
|     { | ||||
|         field: 'updatedAt', | ||||
|         headerName: 'Updated At', | ||||
|         width: 120, | ||||
|         valueFormatter: (params) => { | ||||
|             const date = params?.value; | ||||
|             return date ? new Date(date).toLocaleString() : '—'; | ||||
|         } | ||||
|     }, | ||||
|     { field: 'updatedBy', headerName: 'Updated By', flex: 1 }, | ||||
| ]; | ||||
| 
 | ||||
| export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|     const [rows, setRows] = useState([ | ||||
|         { id: 1, name: 'Fabrics', description: 'Textile materials including silk, cotton, and synthetics.' }, | ||||
|         { id: 2, name: 'Leather Goods', description: 'Leather-based components for luxury goods.' }, | ||||
|         { id: 3, name: 'Metal Accessories', description: 'Buttons, zippers, and hardware in metal.' }, | ||||
|         { id: 4, name: 'Embellishments', description: 'Decorative materials such as beads and sequins.' } | ||||
|     ]); | ||||
| 
 | ||||
| export default function Admin() { | ||||
|     const [rows, setRows] = useState([]); | ||||
|     const [open, setOpen] = useState(false); | ||||
|     const [editingCategory, setEditingCategory] = useState(null); | ||||
|     const [editingData, setEditingData] = useState(null); | ||||
|     const [confirmOpen, setConfirmOpen] = useState(false); | ||||
|     const [rowToDelete, setRowToDelete] = useState(null); | ||||
|     const { handleError } = useApiToast(); | ||||
| 
 | ||||
|     const handleAddOrEditCategory = (category) => { | ||||
|         if (editingCategory) { | ||||
|             setRows(rows.map((row) => (row.id === editingCategory.id ? { ...editingCategory, ...category } : row))); | ||||
|         } else { | ||||
|             const id = rows.length + 1; | ||||
|             setRows([...rows, { id, ...category }]); | ||||
|         } | ||||
|         setOpen(false); | ||||
|         setEditingCategory(null); | ||||
|     }; | ||||
| const hasLoaded = useRef(false); | ||||
| 
 | ||||
| useEffect(() => { | ||||
|   if (!hasLoaded.current) { | ||||
|     loadData(); | ||||
|     hasLoaded.current = true; | ||||
|   } | ||||
| }, []); | ||||
| 
 | ||||
|     const handleEditClick = (params) => { | ||||
|         setEditingCategory(params.row); | ||||
|         setEditingData(params.row); | ||||
|         setOpen(true); | ||||
|     }; | ||||
| 
 | ||||
| @@ -47,10 +61,28 @@ export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|         setConfirmOpen(true); | ||||
|     }; | ||||
| 
 | ||||
|     const confirmDelete = () => { | ||||
|         setRows(rows.filter((row) => row.id !== rowToDelete.id)); | ||||
|         setRowToDelete(null); | ||||
|         setConfirmOpen(false); | ||||
|     const handleConfirmDelete = async () => { | ||||
|         try { | ||||
|             await deleteExternalData(rowToDelete._Id); | ||||
|             await loadData(); | ||||
|         } catch (error) { | ||||
|             console.error('Delete failed:', error); | ||||
|         } finally { | ||||
|             setConfirmOpen(false); | ||||
|             setRowToDelete(null); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const loadData = async () => { | ||||
|         try { | ||||
|             const data = await getExternalData(); | ||||
|             const safeData = Array.isArray(data) ? data : []; | ||||
|             setRows(safeData); | ||||
|         } catch (error) { | ||||
|             console.error('Error loading data:', error); | ||||
|             handleError(error, 'Failed to load data'); | ||||
|             setRows([]); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const columns = [ | ||||
| @@ -66,9 +98,7 @@ export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|                         sx={{ | ||||
|                             backgroundColor: '#DFCCBC', | ||||
|                             color: '#26201A', | ||||
|                             '&:hover': { | ||||
|                                 backgroundColor: '#C2B2A4', | ||||
|                             }, | ||||
|                             '&:hover': { backgroundColor: '#C2B2A4' }, | ||||
|                             borderRadius: 2, | ||||
|                             p: 1, | ||||
|                         }} | ||||
| @@ -81,9 +111,7 @@ export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|                         sx={{ | ||||
|                             backgroundColor: '#FBE9E7', | ||||
|                             color: '#C62828', | ||||
|                             '&:hover': { | ||||
|                                 backgroundColor: '#EF9A9A', | ||||
|                             }, | ||||
|                             '&:hover': { backgroundColor: '#EF9A9A' }, | ||||
|                             borderRadius: 2, | ||||
|                             p: 1, | ||||
|                         }} | ||||
| @@ -98,14 +126,23 @@ export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
| 
 | ||||
|     return ( | ||||
|         <SectionContainer sx={{ width: '100%' }}> | ||||
|             <Typography variant="h4" gutterBottom color='#26201AFF'> | ||||
|                 Categories | ||||
|             </Typography> | ||||
|             <Typography variant="h4" gutterBottom color='#26201AFF'>Admin</Typography> | ||||
| 
 | ||||
|             <Dialog open={open} onClose={() => { setOpen(false); setEditingCategory(null); }} fullWidth> | ||||
|                 <DialogTitle>{editingCategory ? 'Edit Category' : 'Add Category'}</DialogTitle> | ||||
|             <Dialog open={open} onClose={() => { setOpen(false); setEditingData(null); }} maxWidth="md" fullWidth> | ||||
|                 <DialogTitle>{editingData ? 'Edit Item' : 'Add Item'}</DialogTitle> | ||||
|                 <DialogContent> | ||||
|                     <AddOrEditCategoryForm onAdd={handleAddOrEditCategory} initialData={editingCategory} onCancel={() => { setOpen(false); setEditingCategory(null); }} /> | ||||
|                     <AddOrEditAdminForm | ||||
|                         onAdd={async () => { | ||||
|                             await loadData(); | ||||
|                             setOpen(false); | ||||
|                             setEditingData(null); | ||||
|                         }} | ||||
|                         initialData={editingData} | ||||
|                         onCancel={() => { | ||||
|                             setOpen(false); | ||||
|                             setEditingData(null); | ||||
|                         }} | ||||
|                     /> | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
| 
 | ||||
| @@ -117,7 +154,7 @@ export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|                     </Typography> | ||||
|                     <Box mt={2} display="flex" justifyContent="flex-end" gap={1}> | ||||
|                         <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|                         <Button variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button> | ||||
|                         <Button variant="contained" onClick={handleConfirmDelete} className="button-gold">Delete</Button> | ||||
|                     </Box> | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
| @@ -128,15 +165,15 @@ export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|                     columns={columns} | ||||
|                     pageSize={5} | ||||
|                     rowsPerPageOptions={[5]} | ||||
|                     getRowSpacing={() => ({ top: 8, bottom: 8 })} | ||||
|                     getRowSpacing={() => ({ top: 4, bottom: 4 })} | ||||
|                 /> | ||||
| 
 | ||||
|                 <Box display="flex" justifyContent="flex-end" mt={2}> | ||||
|                     <Button variant="contained" onClick={() => setOpen(true)} className="button-gold"> | ||||
|                         Add Category | ||||
|                         Add Item | ||||
|                     </Button> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </SectionContainer> | ||||
|     ); | ||||
| } | ||||
| } | ||||
| @@ -21,7 +21,7 @@ const theme = createTheme({ | ||||
|             backgroundColor: '#f0eae3', | ||||
|           }, | ||||
|           '&.Mui-selected': { | ||||
|             backgroundColor: '#DFCCBCFF', | ||||
|             backgroundColor: '#d0b9a8', | ||||
|             color: '#26201A', | ||||
|           }, | ||||
|           '&.Mui-selected:hover': { | ||||
| @@ -31,7 +31,7 @@ const theme = createTheme({ | ||||
|         }, | ||||
|         cell: { | ||||
|           '&:focus-within': { | ||||
|             outline: '2px solid #DFCCBCFF', // custom Fendi focus | ||||
|             outline: '2px solid #d0b9a8', // custom Fendi focus | ||||
|             outlineOffset: '-2px', // tighten the outline | ||||
|             backgroundColor: '#f5f0eb', // optional subtle highlight | ||||
|           }, | ||||
| @@ -43,10 +43,10 @@ const theme = createTheme({ | ||||
|         root: { | ||||
|           /* your current styles... */ | ||||
|           '&:focus': { | ||||
|             outline: '2px solid #DFCCBCFF', | ||||
|             outline: '2px solid #fff4ec', | ||||
|           }, | ||||
|           '&:focusVisible': { | ||||
|             outline: '2px solid #DFCCBCFF', | ||||
|             outline: '2px solid #fff4ec', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
| @@ -55,7 +55,7 @@ const theme = createTheme({ | ||||
|       styleOverrides: { | ||||
|         root: { | ||||
|           '&:focus, &:focus-visible': { | ||||
|             outline: '2px solid #DFCCBCFF', | ||||
|             outline: '2px solid #fff4ec', | ||||
|             outlineOffset: '2px', | ||||
|           }, | ||||
|         }, | ||||
| @@ -66,7 +66,7 @@ const theme = createTheme({ | ||||
|         root: { | ||||
|           transition: 'background-color 0.2s ease-in-out', | ||||
|           '&:hover': { | ||||
|             backgroundColor: '#AA7665FF', | ||||
|             backgroundColor: '#fff4ec', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|   | ||||