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