Compare commits

...

58 Commits

Author SHA1 Message Date
Rodolfo Ruiz
f7adaf1b18 chore: clean code, add delete 2025-09-06 20:45:56 -06:00
Rodolfo Ruiz
b3209a4019 chore: delete implementation in product collection 2025-09-06 20:34:46 -06:00
Rodolfo Ruiz
efdb48919f chore: add the logic to see in view mode the categories as well 2025-09-06 20:08:39 -06:00
Rodolfo Ruiz
6b8d5acc0d chore: add the view button 2025-09-06 20:04:38 -06:00
Rodolfo Ruiz
01a19b9144 chore: improve the ui for Products 2025-09-06 19:53:10 -06:00
Rodolfo Ruiz
74d6a8b269 chore: fix the edit popup 2025-09-06 19:48:21 -06:00
Rodolfo Ruiz
c33de6ada5 chore: show all the columns from db 2025-09-06 19:19:40 -06:00
Rodolfo Ruiz
15107a48bd chore: show all the fields from the getAll 2025-09-06 18:52:45 -06:00
Rodolfo Ruiz
73699009fc chore: fix products columns 2025-09-06 18:44:42 -06:00
Rodolfo Ruiz
55dc96085d chore: add filters 2025-09-06 18:34:09 -06:00
Rodolfo Ruiz
9cdb76273d chore: organize names and folders to better fit 2025-09-06 18:30:32 -06:00
Rodolfo Ruiz
49dead566c chore: update, delete and create 2025-09-05 19:17:17 -06:00
Rodolfo Ruiz
2fa6b95012 chore: create items 2025-09-05 18:50:11 -06:00
Rodolfo Ruiz
f5acde78de chore: show material in the grid 2025-09-05 18:06:32 -06:00
Rodolfo Ruiz
d9bfaba977 chore: show material in the edit form, needs to test save button 2025-09-04 21:35:26 -06:00
Rodolfo Ruiz
f42d08c091 chore: show dates 2025-09-04 21:25:30 -06:00
Rodolfo Ruiz
aa62b06c23 chore: load TagType and materials, show in the gridview and the edit 2025-09-04 21:16:00 -06:00
Rodolfo Ruiz
d699af9d75 chore: hide dates and by columns 2025-09-04 20:51:46 -06:00
Rodolfo Ruiz
fead820091 chore: add Material column and show the Parent array 2025-09-04 20:47:53 -06:00
Rodolfo Ruiz
c69252cc1a chore: increase the pagination 2025-09-04 20:38:39 -06:00
Rodolfo Ruiz
2cb7264450 chore: fix the widh of the grid 2025-09-04 20:35:32 -06:00
Rodolfo Ruiz
f51382164d chore: show right path and only fields without ids in categories gridview 2025-09-03 21:22:51 -06:00
Rodolfo Ruiz
e8e2ed4ff1 chore: rename edit product component 2025-09-03 18:09:16 -06:00
Rodolfo Ruiz
2184183071 chore: change name of folder and page 2025-09-03 18:07:02 -06:00
7e88f9ac4b feat: added dropdowns, categories and some filters 2025-09-03 16:03:31 -06:00
Rodolfo Ruiz
cb27e16a10 chore: change dialog title 2025-09-02 17:24:29 -06:00
Rodolfo Ruiz
16d95b11f8 chore: refresh gridview after Add, Delete or Edit 2025-09-01 20:58:28 -06:00
Rodolfo Ruiz
304d5a6e59 chore: edit existing item 2025-09-01 20:53:04 -06:00
24f82889e1 features: services and filters implemented 2025-09-01 19:09:48 -06:00
2dc6cf9fcb feat: tags fixes 2025-09-01 17:31:33 -06:00
0a74c7a22a feat: added categories 2025-09-01 17:19:06 -06:00
af323aade7 feat: fixed logo and made login a "separate" page 2025-09-01 16:53:07 -06:00
Rodolfo Ruiz
ec2d7d6637 chore: delete 2025-09-01 14:30:40 -06:00
Rodolfo Ruiz
b2488ba7d9 chore: add and Edit Products 2025-09-01 14:24:24 -06:00
Rodolfo Ruiz
e55d9a8cf4 chore: add new forniture page 2025-09-01 13:45:22 -06:00
Rodolfo Ruiz
b79d976c3e chore: show complete route 2025-09-01 13:20:00 -06:00
Rodolfo Ruiz
38626a3a81 chore: fix delete button 2025-08-31 20:44:22 -06:00
Rodolfo Ruiz
347e61a029 chore: Fix add and edit User 2025-08-31 20:27:39 -06:00
Rodolfo Ruiz
bec10610e1 chore: add edit form 2025-08-30 21:01:29 -06:00
Rodolfo Ruiz
55cb3fb34f chore: add data to gridview 2025-08-30 19:40:13 -06:00
Rodolfo Ruiz
1f11c47484 chore: add class to get users from thalos bff 2025-08-30 11:49:33 -06:00
Rodolfo Ruiz
2116e134a9 chore: Make the main container dynamic based on the with of the menudrawer 2025-08-29 21:59:16 -06:00
Rodolfo Ruiz
2eeb0b42d8 chore; change favicon and app title 2025-08-29 21:21:30 -06:00
Rodolfo Ruiz
3115d45135 chore: get token from google, add the thalos connector and get and persist the thalos token id for future use in other endpoints 2025-08-29 21:14:25 -06:00
Rodolfo Ruiz
339bad77ac chore: get the jwt from google 2025-08-29 20:44:53 -06:00
Rodolfo Ruiz
d8c890313f feat: add the new menu for admin and the users page 2025-08-28 21:45:07 -06:00
Rodolfo Ruiz
91ed5ccaa5 chore: add the new menu for private mode - admin 2025-08-28 20:58:02 -06:00
Rodolfo Ruiz
e85a401209 chore: align the dashboards 2025-08-21 21:34:27 -06:00
Rodolfo Ruiz
d59632e1f6 chore: update color schemas 2025-08-21 21:25:59 -06:00
Rodolfo Ruiz
4fbc9e40f8 chore: fix header and show a mockup dashboard page 2025-08-21 20:53:59 -06:00
Rodolfo Ruiz
4368723f3f chore: add logo to footer and same look and feel as header 2025-08-21 20:28:30 -06:00
Rodolfo Ruiz
6b493265b2 chore: align the user name and icons to the right 2025-08-21 20:14:19 -06:00
Rodolfo Ruiz
cfdad88682 chore: align the page name to the right 2025-08-21 20:04:13 -06:00
Rodolfo Ruiz
86dd772e9d chore: add logic to show the current Page and move based on the collapse flag 2025-08-21 17:34:24 -06:00
Rodolfo Ruiz
0d329784c5 chore: fix header and add icons 2025-08-21 16:24:08 -06:00
Rodolfo Ruiz
e4c8ed534d chore: adding collapsable submenu to Catalog 2025-08-14 17:22:45 -06:00
Rodolfo Ruiz
6300421693 feat: show drawer collapsed 2025-08-12 17:31:48 -06:00
Rodolfo Ruiz
4ac86a9097 chore: show icons only when collapsed 2025-08-12 17:13:24 -06:00
40 changed files with 3586 additions and 482 deletions

View File

@@ -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>

345
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@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",
@@ -266,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"
@@ -1388,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"
@@ -1405,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"
@@ -1434,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",
@@ -1472,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",
@@ -1838,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",
@@ -1859,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": {
@@ -2011,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",
@@ -2310,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",
@@ -2334,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",
@@ -3152,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",
@@ -3877,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": {
@@ -3990,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",

View File

@@ -15,6 +15,7 @@
"@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",

BIN
public/Expand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
public/MiniLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/alert.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

BIN
public/refresh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -1,5 +1,5 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;

View File

@@ -1,68 +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 Products from './private/products/Products';
import Clients from './private/clients/Clients';
import Providers from './private/providers/Providers';
import Categories from './private/categories/Categories';
import Admin from './private/mongo/Admin';
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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
function PrivateRoute({ children }) {
const { user } = useAuth();
return user ? children : <Navigate to="/login" replace />;
}
const DRAWER_EXPANDED = OPEN_WIDTH;
const DRAWER_COLLAPSED = MINI_WIDTH;
const APPBAR_HEIGHT = 64;
function App() {
const [zone, setZone] = useState('public'); // 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)} />
<Box component="main" sx={{ flex: 1, p: 2 }}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<PrivateRoute>
{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 />}
{zone === 'public' && currentView === 'Admin' && <Admin />}
</PrivateRoute>
}
/>
</Routes>
</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
View 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
View 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
View 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();
}
}

70
src/api/userApi.js Normal file
View 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;
}
}
}

View 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,
};

View File

@@ -1,69 +1,72 @@
import { useState } from 'react';
import fendiLogo from '/favicon.png'
import { AppBar, Toolbar, Typography, IconButton, Box, Avatar } from '@mui/material';
import MenuDrawer from './MenuDrawer';
import MenuIcon from '@mui/icons-material/Menu';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
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 { user, logout } = useAuth();
const isPrivate = zone === 'private';
const isRestricted = zone === 'restricted';
const isPublic = zone === 'public';
const navigate = useNavigate();
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>
{/* Login button only visible for public zone */}
{isPublic && !user && (
<Box>
<IconButton color="inherit" onClick={() => navigate('/login')}>
<Typography variant="button" color="#A68A72FF">
Login
<Box
sx={{
position: 'absolute',
right: 20,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<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>
)}
{user && (
<Box display="flex" alignItems="center" gap={2}>
<Typography variant="body1">{user.name}</Typography>
<Avatar alt={user.name} src={user.picture} />
</Box>
)}
{/* Rendering the Drawer */}
<MenuDrawer
zone="private"
open={menuOpen}
onClose={() => setMenuOpen(false)}
onSelect={onSelectMenuItem} // pass handler from App
/>
</Box>
)}
</Box>
</Toolbar>
</AppBar>

View File

@@ -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>
);
}

View File

@@ -1,12 +1,29 @@
import { Drawer, List, ListItem, ListItemText, ListItemIcon, Typography, Box, useMediaQuery, InputBase } from '@mui/material';
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 ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
const menuOptions = {
public: [
{ text: 'Dashboard', icon: <img src="/Dashboard.png" alt="Dashboard" width={24} height={24} />},
{ text: 'Dashboard', icon: <img src="/Dashboard.png" alt="Dashboard" width={24} height={24} /> },
{ text: 'Logout', icon: <ExitToAppIcon /> },
],
restricted: [],
@@ -24,72 +41,268 @@ const menuOptions = {
],
};
export default function MenuDrawer({ zone = 'public', open, onClose, onSelect }) {
const isMobile = useMediaQuery('(max-width:900px)');
const items = menuOptions[zone];
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 = 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: {
<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',
width: isMobile ? '100vw' : 300,
color: '#40120EFF'
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}>
<img
src="/logo.png"
alt="Dream Views"
style={{ margin: 'auto', marginBottom: 8 }}
/>
}}
>
<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"
/>
<Box sx={{ position: 'relative', display: { xs: 'none', md: 'flex' } }}>
<InputBase
placeholder="Filter options..."
sx={{
pl: 4,
pr: 2,
py: 0.5,
borderRadius: 2,
border: '1px solid #40120EFF', // Borde visible
color: '#40120EFF',
width: { md: '300px', lg: '400px' }
}}
/>
</Box>
</Box>
<List sx={{ width: isMobile ? '100vw' : 250, marginTop: 0 }}>
{items.map(({ text, icon }, index) => (
<ListItem key={index} onClick={() => {
onClose(); // Close drawer
onSelect?.(text); // Notify parent of selected item
if (text === 'Logout') {
logout(); // cerrar sesión y redirigir
} else {
onSelect?.(text); // navegar al resto de vistas
}
}}>
<ListItemIcon sx={{ color: '#40120EFF' }}>{icon}</ListItemIcon>
<ListItemText
primary={text}
slotProps={{
primary: {
sx: {
color: '#40120EFF',
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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -4,15 +4,18 @@ 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 = () => {
@@ -21,7 +24,7 @@ export function AuthProvider({ children }) {
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
<AuthContext.Provider value={{ user, login, logout, initializing }}>
{children}
</AuthContext.Provider>
);
@@ -29,4 +32,4 @@ export function AuthProvider({ children }) {
export function useAuth() {
return useContext(AuthContext);
}
}

View File

@@ -1,29 +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';
import { Box, Typography } from '@mui/material';
export default function LoginPage() {
const [googleIdToken, setGoogleIdToken] = useState(null);
const [googleProfile, setGoogleProfile] = useState(null);
const { login } = useAuth();
const navigate = useNavigate();
return (
<Box display="flex" flexDirection="column" alignItems="center" mt={10}>
<Typography variant="h4" gutterBottom>
Sign in with Google
</Typography>
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
<Paper sx={{ p: 4, borderRadius: 2, boxShadow: 3, textAlign: 'center' }}>
<GoogleLogin
onSuccess={(credentialResponse) => {
const user = jwtDecode(credentialResponse.credential);
login(user);
navigate('/');
}}
onError={() => {
console.log('Error signing in');
}}
/>
<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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,142 +0,0 @@
import SectionContainer from '../../components/SectionContainer.jsx';
import { useState } 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';
const columnsBase = [
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'description', headerName: 'Description', flex: 2 }
];
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.' }
]);
const [open, setOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [rowToDelete, setRowToDelete] = useState(null);
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 handleEditClick = (params) => {
setEditingCategory(params.row);
setOpen(true);
};
const handleDeleteClick = (row) => {
setRowToDelete(row);
setConfirmOpen(true);
};
const confirmDelete = () => {
setRows(rows.filter((row) => row.id !== rowToDelete.id));
setRowToDelete(null);
setConfirmOpen(false);
};
const columns = [
...columnsBase,
{
field: 'actions',
headerName: '',
width: 130,
renderCell: (params) => (
<Box display="flex" alignItems="center" justifyContent="flex-end" height="100%" gap={2}>
<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>
)
}
];
return (
<SectionContainer sx={{ width: '100%' }}>
<Typography variant="h4" gutterBottom color='#26201AFF'>
Categories
</Typography>
<Dialog open={open} onClose={() => { setOpen(false); setEditingCategory(null); }} fullWidth>
<DialogTitle>{editingCategory ? 'Edit Category' : 'Add Category'}</DialogTitle>
<DialogContent>
<AddOrEditCategoryForm onAdd={handleAddOrEditCategory} initialData={editingCategory} onCancel={() => { setOpen(false); setEditingCategory(null); }} />
</DialogContent>
</Dialog>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete <strong>{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={confirmDelete} className="button-gold">Delete</Button>
</Box>
</DialogContent>
</Dialog>
<Box mt={2}>
<DataGrid
rows={rows}
columns={columns}
pageSize={5}
rowsPerPageOptions={[5]}
getRowSpacing={() => ({ top: 8, bottom: 8 })}
/>
<Box display="flex" justifyContent="flex-end" mt={2}>
<Button variant="contained" onClick={() => setOpen(true)} className="button-gold">
Add Category
</Button>
</Box>
</Box>
</SectionContainer>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -21,7 +21,7 @@ const theme = createTheme({
backgroundColor: '#f0eae3',
},
'&.Mui-selected': {
backgroundColor: '#40120EFF',
backgroundColor: '#d0b9a8',
color: '#26201A',
},
'&.Mui-selected:hover': {
@@ -31,7 +31,7 @@ const theme = createTheme({
},
cell: {
'&:focus-within': {
outline: '2px solid #40120EFF', // custom Fendi focus
outline: '2px solid #d0b9a8', // custom Fendi focus
outlineOffset: '-2px', // tighten the outline
backgroundColor: '#f5f0eb', // optional subtle highlight
},