Compare commits
	
		
			38 Commits
		
	
	
		
			bec10610e1
			...
			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 | 
| Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB | 
							
								
								
									
										58
									
								
								src/App.jsx
									
									
									
									
									
								
							
							
						
						
									
										58
									
								
								src/App.jsx
									
									
									
									
									
								
							| @@ -6,42 +6,42 @@ import MenuDrawerPrivate, { OPEN_WIDTH, MINI_WIDTH } from './components/MenuDraw | ||||
| import Footer from './components/Footer'; | ||||
| import Dashboard from './private/dashboard/Dashboard'; | ||||
| import UserManagement from './private/users/UserManagement'; | ||||
| import ProductCollections from './private/catalogs/products/ProductCollections'; | ||||
| import Categories from './private/catalogs/categories/Categories'; | ||||
| import LoginPage from './private/LoginPage'; | ||||
| import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; | ||||
|  | ||||
| import { Routes, Route, Navigate } from 'react-router-dom'; | ||||
| import { useAuth } from './context/AuthContext'; | ||||
|  | ||||
| const DRAWER_EXPANDED = OPEN_WIDTH; | ||||
| const DRAWER_COLLAPSED = MINI_WIDTH; | ||||
| const APPBAR_HEIGHT = 64; | ||||
|  | ||||
| function PrivateRoute({ children }) { | ||||
|   const { user } = useAuth(); | ||||
|   return user ? children : <Navigate to="/login" replace />; | ||||
| } | ||||
|  | ||||
| export default function App() { | ||||
|   const theme = useTheme(); | ||||
|   const isMobile = useMediaQuery('(max-width:900px)'); | ||||
|   const [zone, setZone] = useState('public'); // public | restricted | private | ||||
|  | ||||
|   const [drawerExpanded, setDrawerExpanded] = useState(true); | ||||
|   const [currentView, setCurrentView] = useState('Dashboard'); | ||||
|   const { user, initializing } = useAuth(); | ||||
|  | ||||
|   const mainLeft = isMobile ? 0 : (drawerExpanded ? DRAWER_EXPANDED : DRAWER_COLLAPSED); | ||||
|  | ||||
|   if (initializing) return null; | ||||
|  | ||||
|   if (!user) { | ||||
|     return ( | ||||
|       <Routes> | ||||
|         <Route path="/login" element={<LoginPage />} /> | ||||
|         <Route path="*" element={<Navigate to="/login" replace />} /> | ||||
|       </Routes> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <AppHeader | ||||
|         zone="private" | ||||
|         onSelectMenuItem={setCurrentView} | ||||
|         drawerExpanded={drawerExpanded} | ||||
|       /> | ||||
|       <AppHeader zone="private" currentPage={currentView} leftOffset={mainLeft} /> | ||||
|  | ||||
|       <MenuDrawerPrivate | ||||
|         onSelect={(value) => { | ||||
|           setCurrentView(value === '/users/UserManagement' ? 'UserManagement' : value); | ||||
|         }} | ||||
|         onSelect={(value) => setCurrentView(value)} | ||||
|         onExpandedChange={(expanded) => setDrawerExpanded(expanded)} | ||||
|       /> | ||||
|  | ||||
| @@ -58,27 +58,27 @@ export default function App() { | ||||
|         }} | ||||
|       > | ||||
|         <Routes> | ||||
|           <Route path="/login" element={<LoginPage />} /> | ||||
|  | ||||
|           <Route path="/login" element={<Navigate to="/" replace />} /> | ||||
|           <Route | ||||
|             path="/" | ||||
|             element={ | ||||
|               <PrivateRoute> | ||||
|                 {zone === 'private' && <Clients />} | ||||
|                 {zone === 'restricted' && <Clients />} | ||||
|  | ||||
|                 {zone === 'public' && currentView === 'Dashboard' && <Dashboard />} | ||||
|  | ||||
|                 {zone === 'public' && currentView === 'UserManagement' && <UserManagement />} | ||||
|               </PrivateRoute> | ||||
|               <> | ||||
|                 {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={zone} /> | ||||
|  | ||||
|       <Footer zone="private" /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										116
									
								
								src/api/CategoriesApi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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(); | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import React, { useMemo, useState, useEffect } from 'react'; | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Drawer, List, ListItemButton, ListItemIcon, ListItemText, | ||||
|     Collapse, IconButton, Tooltip, Box, useMediaQuery, InputBase | ||||
| @@ -40,11 +40,13 @@ const menuData = [ | ||||
|                             { title: 'Categories' } | ||||
|                         ] | ||||
|                     }, | ||||
|                     { title: 'Products', | ||||
|                     { | ||||
|                         title: 'Products', | ||||
|                         children: [ | ||||
|                             { title: 'AR  Assets Library Management' }, | ||||
|                             { title: 'Media Management' }, | ||||
|                     ] }, | ||||
|                         ] | ||||
|                     }, | ||||
|                     { title: 'Product Collections' }, | ||||
|                 ] | ||||
|             } | ||||
| @@ -54,7 +56,8 @@ const menuData = [ | ||||
|         title: 'Customers', | ||||
|         icon: <PeopleAltIcon />, | ||||
|         children: [ | ||||
|             { title: 'CRM', | ||||
|             { | ||||
|                 title: 'CRM', | ||||
|                 children: [ | ||||
|                     { title: 'Customer List' }, | ||||
|                     { title: 'Projects' }, | ||||
| @@ -86,7 +89,8 @@ const menuData = [ | ||||
|         icon: <AdminPanelSettingsIcon />, | ||||
|         children: [ | ||||
|             { title: 'Users Management' }, | ||||
|             { title: 'Access Control', | ||||
|             { | ||||
|                 title: 'Access Control', | ||||
|                 children: [ | ||||
|                     { title: 'Roles' }, | ||||
|                     { title: 'Permissions' }, | ||||
| @@ -154,7 +158,11 @@ export default function MenuDrawerPrivate({ | ||||
|                                 handleToggleNode(key); | ||||
|                             } else { | ||||
|                                 if (node.title === 'Users Management') { | ||||
|                                     onSelect?.('UserManagement'); | ||||
|                                     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); | ||||
|                                 } | ||||
| @@ -197,7 +205,7 @@ export default function MenuDrawerPrivate({ | ||||
|                 {hasChildren && !collapsed && ( | ||||
|                     <Collapse in={!!openMap[key]} timeout="auto" unmountOnExit> | ||||
|                         <List component="div" disablePadding sx={{ pl: 7 }}> | ||||
|                             {node.children.map((child, idx) => renderNode(child, `${key}-`))} | ||||
|                             {node.children.map((child) => renderNode(child, `${key}-`))} | ||||
|                         </List> | ||||
|                     </Collapse> | ||||
|                 )} | ||||
|   | ||||
| @@ -4,10 +4,12 @@ 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) => { | ||||
| @@ -22,7 +24,7 @@ export function AuthProvider({ children }) { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <AuthContext.Provider value={{ user, login, logout }}> | ||||
|     <AuthContext.Provider value={{ user, login, logout, initializing }}> | ||||
|       {children} | ||||
|     </AuthContext.Provider> | ||||
|   ); | ||||
|   | ||||
| @@ -15,8 +15,15 @@ export default function LoginPage() { | ||||
|     return ( | ||||
|         <Box display="flex" justifyContent="center" alignItems="center" height="100vh"> | ||||
|             <Paper sx={{ p: 4, borderRadius: 2, boxShadow: 3, textAlign: 'center' }}> | ||||
|                 <Typography variant="h5" mb={2}>Login to Dream Views</Typography> | ||||
|  | ||||
|                 <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; | ||||
| @@ -42,7 +49,7 @@ export default function LoginPage() { | ||||
|                                 idToken: googleIdToken, | ||||
|                                 thalosToken: payload?.token || '', | ||||
|                             }); | ||||
|                             navigate('/'); | ||||
|                             navigate('/', { replace: true }); | ||||
|                         }} | ||||
|                         onError={(err) => { | ||||
|                             console.error('Thalos exchange failed:', err); | ||||
| @@ -50,6 +57,15 @@ export default function LoginPage() { | ||||
|                         }} | ||||
|                     /> | ||||
|                 )} | ||||
|  | ||||
|                 <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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										397
									
								
								src/private/catalogs/products/ProductCollections.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,397 @@ | ||||
| import SectionContainer from '../../../components/SectionContainer'; | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { DataGrid } from '@mui/x-data-grid'; | ||||
| import { | ||||
|   Typography, Button, Dialog, DialogTitle, DialogContent, | ||||
|   IconButton, Box, ToggleButtonGroup, ToggleButton | ||||
| } from '@mui/material'; | ||||
| import EditRoundedIcon from '@mui/icons-material/EditRounded'; | ||||
| import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; | ||||
| import AddRoundedIcon from '@mui/icons-material/AddRounded'; | ||||
| import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; | ||||
| import AddOrEditProductCollectionForm from './AddOrEditProductCollectionForm'; | ||||
| import FurnitureVariantApi from '../../../api/ProductsApi'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import TagTypeApi from '../../../api/TagTypeApi'; | ||||
| import { useAuth } from '../../../context/AuthContext'; | ||||
| import useApiToast from '../../../hooks/useApiToast'; | ||||
|  | ||||
| const parsePrice = (p) => { | ||||
|   if (p == null) return 0; | ||||
|   if (typeof p === 'number') return p; | ||||
|   if (typeof p === 'string') return Number(p) || 0; | ||||
|   if (typeof p === 'object' && p.$numberDecimal) return Number(p.$numberDecimal) || 0; | ||||
|   return 0; | ||||
| }; | ||||
|  | ||||
| const TYPE_NAMES = { | ||||
|   category: 'Furniture category', | ||||
|   provider: 'Provider', | ||||
|   color: 'Color', | ||||
|   line: 'Line', | ||||
|   currency: 'Currency', | ||||
|   material: 'Material', | ||||
|   legs: 'Legs', | ||||
|   origin: 'Origin', | ||||
| }; | ||||
|  | ||||
| export default function ProductCollections() { | ||||
|   const { user } = useAuth(); | ||||
|   const token = user?.thalosToken || localStorage.getItem('thalosToken'); | ||||
|  | ||||
|   const api = useMemo(() => new FurnitureVariantApi(token), [token]); | ||||
|   const tagTypeApi = useMemo(() => new TagTypeApi(token), [token]); | ||||
|   const categoriesApi = useMemo(() => new CategoriesApi(token), [token]); | ||||
|  | ||||
|   const { handleError } = useApiToast(); | ||||
|  | ||||
|   const [rows, setRows] = useState([]); | ||||
|   const [rawRows, setRawRows] = useState([]); | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [editRow, setEditRow] = useState(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   const [statusFilter, setStatusFilter] = useState('All'); | ||||
|  | ||||
|   const [viewOnly, setViewOnly] = useState(false); | ||||
|  | ||||
|   const [confirmOpen, setConfirmOpen] = useState(false); | ||||
|  | ||||
|   // Tags | ||||
|   const [loadingTags, setLoadingTags] = useState(true); | ||||
|   const [typeMap, setTypeMap] = useState({}); | ||||
|   const [byType, setByType] = useState({ | ||||
|     [TYPE_NAMES.category]: [], | ||||
|     [TYPE_NAMES.provider]: [], | ||||
|     [TYPE_NAMES.color]: [], | ||||
|     [TYPE_NAMES.line]: [], | ||||
|     [TYPE_NAMES.currency]: [], | ||||
|     [TYPE_NAMES.material]: [], | ||||
|     [TYPE_NAMES.legs]: [], | ||||
|     [TYPE_NAMES.origin]: [], | ||||
|   }); | ||||
|  | ||||
|   const buildLabelResolver = (typeName) => { | ||||
|     const list = byType[typeName] || []; | ||||
|     return (value) => { | ||||
|       if (!value && value !== 0) return '—'; | ||||
|       if (list.some(t => t.tagName === value)) return value; // ya es tagName | ||||
|       const found = list.find(t => | ||||
|         t.id === value || | ||||
|         t._id === value || | ||||
|         t._id?.$oid === value || | ||||
|         t.slug === value | ||||
|       ); | ||||
|       return found?.tagName || String(value); | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   // Cargar TagTypes + Tags | ||||
|   useEffect(() => { | ||||
|     let mounted = true; | ||||
|     (async () => { | ||||
|       try { | ||||
|         const [types, tags] = await Promise.all([tagTypeApi.getAll(), categoriesApi.getAll()]); | ||||
|         const tmap = {}; | ||||
|         types?.forEach(t => { | ||||
|           if (!t?.typeName || !t?._id) return; | ||||
|           tmap[t.typeName] = t._id; | ||||
|         }); | ||||
|         const group = (tname) => { | ||||
|           const tid = tmap[tname]; | ||||
|           if (!tid) return []; | ||||
|           return (tags || []).filter(tag => tag?.typeId === tid); | ||||
|         }; | ||||
|         if (mounted) { | ||||
|           setTypeMap(tmap); | ||||
|           setByType({ | ||||
|             [TYPE_NAMES.category]: group(TYPE_NAMES.category), | ||||
|             [TYPE_NAMES.provider]: group(TYPE_NAMES.provider), | ||||
|             [TYPE_NAMES.color]: group(TYPE_NAMES.color), | ||||
|             [TYPE_NAMES.line]: group(TYPE_NAMES.line), | ||||
|             [TYPE_NAMES.currency]: group(TYPE_NAMES.currency), | ||||
|             [TYPE_NAMES.material]: group(TYPE_NAMES.material), | ||||
|             [TYPE_NAMES.legs]: group(TYPE_NAMES.legs), | ||||
|             [TYPE_NAMES.origin]: group(TYPE_NAMES.origin), | ||||
|           }); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error('Failed loading TagTypes/Tags', e); | ||||
|       } finally { | ||||
|         if (mounted) setLoadingTags(false); | ||||
|       } | ||||
|     })(); | ||||
|     return () => { mounted = false; }; | ||||
|   }, [tagTypeApi, categoriesApi]); | ||||
|  | ||||
|   // Cargar variants | ||||
|   const load = async () => { | ||||
|     try { | ||||
|       setLoading(true); | ||||
|       const data = await api.getAllVariants(); | ||||
|       const normalized = (data || []).map((r, idx) => ({ | ||||
|         id: r.id || r._id || `row-${idx}`, | ||||
|         _Id: r._id || r._Id || '', | ||||
|         modelId: r.modelId ?? '', | ||||
|         name: r.name ?? '', | ||||
|         categoryId: r.categoryId ?? '', | ||||
|         providerId: r.providerId ?? '', | ||||
|         color: r.color ?? '', | ||||
|         line: r.line ?? '', | ||||
|         stock: Number(r.stock ?? 0), | ||||
|         price: parsePrice(r.price), | ||||
|         currency: r.currency ?? 'USD', | ||||
|         attributes: { | ||||
|           material: r?.attributes?.material ?? '', | ||||
|           legs: r?.attributes?.legs ?? '', | ||||
|           origin: r?.attributes?.origin ?? '', | ||||
|         }, | ||||
|         status: r.status ?? 'Active', | ||||
|         createdAt: r.createdAt ?? null, | ||||
|         createdBy: r.createdBy ?? null, | ||||
|       })); | ||||
|       setRawRows(normalized); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|       handleError(err, 'Error loading product collections'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { load(); /* eslint-disable-next-line */ }, []); | ||||
|   useEffect(() => { | ||||
|     if (statusFilter === 'All') { | ||||
|       setRows(rawRows); | ||||
|     } else { | ||||
|       const want = statusFilter.toLowerCase(); | ||||
|       setRows(rawRows.filter(r => String(r.status ?? 'Active').toLowerCase() === want)); | ||||
|     } | ||||
|   }, [statusFilter, rawRows]); | ||||
|  | ||||
|   const handleDeleteClick = (row) => { | ||||
|     setEditRow(row); | ||||
|     setConfirmOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const confirmDelete = async () => { | ||||
|     try { | ||||
|       if (!editRow?.id) return; | ||||
|       await api.changeStatusVariant({ mongoId: editRow._Id, status: 'Inactive' }); | ||||
|       await load(); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|     } finally { | ||||
|       setConfirmOpen(false); | ||||
|       setEditRow(null); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const columns = [ | ||||
|     { | ||||
|       field: 'actions', | ||||
|       headerName: '', | ||||
|       width: 190, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|       disableExport: true, | ||||
|       renderCell: (params) => ( | ||||
|         <Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#DFCCBC', | ||||
|               color: '#26201A', | ||||
|               '&:hover': { backgroundColor: '#C2B2A4' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { setEditRow(params.row); setViewOnly(false); setOpen(true); }} | ||||
|           > | ||||
|             <EditRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#E3F2FD', | ||||
|               color: '#1565C0', | ||||
|               '&:hover': { backgroundColor: '#BBDEFB' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { setEditRow(params.row); setOpen(true); setViewOnly(true); }} | ||||
|           > | ||||
|             <VisibilityRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|           <IconButton | ||||
|             size="small" | ||||
|             sx={{ | ||||
|               backgroundColor: '#FBE9E7', | ||||
|               color: '#C62828', | ||||
|               '&:hover': { backgroundColor: '#EF9A9A' }, | ||||
|               borderRadius: 2, | ||||
|               p: 1, | ||||
|             }} | ||||
|             onClick={() => { setViewOnly(false); handleDeleteClick(params?.row); }} | ||||
|           > | ||||
|             <DeleteRoundedIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|         </Box> | ||||
|       ), | ||||
|     }, | ||||
|     { field: 'name', headerName: 'Name', flex: 1, minWidth: 160 }, | ||||
|     { field: 'categoryId', headerName: 'Category', flex: 1, minWidth: 160 }, | ||||
|     { field: 'providerId', headerName: 'Provider', flex: 1, minWidth: 160 }, | ||||
|     { field: 'color', headerName: 'Color', flex: 1, minWidth: 160 }, | ||||
|     { field: 'line', headerName: 'Line', flex: 1, minWidth: 160 }, | ||||
|     { | ||||
|       field: 'price', | ||||
|       headerName: 'Price', | ||||
|       flex: 1, | ||||
|       minWidth: 160, | ||||
|       type: 'number', | ||||
|       valueGetter: (p) => parsePrice(p?.row?.price), | ||||
|       renderCell: (p) => { | ||||
|         const val = parsePrice(p?.row?.price); | ||||
|         const currency = p?.row?.currency || 'USD'; | ||||
|         try { | ||||
|           return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(val); | ||||
|         } catch { | ||||
|           return `${currency} ${val.toFixed(2)}`; | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { field: 'currency', headerName: 'Currency', flex: 1, minWidth: 160 }, | ||||
|     { field: 'stock', headerName: 'Stock', flex: 1, minWidth: 160 }, | ||||
|     { | ||||
|       field: 'attributes', | ||||
|       headerName: 'Attributes', | ||||
|       minWidth: 300, | ||||
|       flex: 1.5, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|       renderCell: (params) => { | ||||
|         const a = params?.row?.attributes || {}; | ||||
|         const chips = Object.entries(a).filter(([, v]) => !!v); | ||||
|         return ( | ||||
|           <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> | ||||
|             {chips.map(([key, value]) => ( | ||||
|               <Box | ||||
|                 key={key} | ||||
|                 component="span" | ||||
|                 sx={{ | ||||
|                   px: 1.5, | ||||
|                   py: 0.5, | ||||
|                   borderRadius: '12px', | ||||
|                   backgroundColor: '#DFCCBC', | ||||
|                   fontSize: 12, | ||||
|                   color: '#26201A', | ||||
|                   lineHeight: '18px', | ||||
|                 }} | ||||
|               > | ||||
|                 {key}: {value} | ||||
|               </Box> | ||||
|             ))} | ||||
|           </Box> | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|     { field: 'status', headerName: 'Status', flex: 1, minWidth: 160 } | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         height: 'calc(100vh - 64px - 64px)', | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         gap: 2, | ||||
|       }} | ||||
|     > | ||||
|       <Box display="flex" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={2}> | ||||
|         <Typography color='text.primary' variant="h6">Product Collection</Typography> | ||||
|         <Box display="flex" alignItems="center" gap={2}> | ||||
|           <ToggleButtonGroup | ||||
|             value={statusFilter} | ||||
|             exclusive | ||||
|             onChange={(_, v) => v && setStatusFilter(v)} | ||||
|             size="small" | ||||
|           > | ||||
|             <ToggleButton value="Active">Active</ToggleButton> | ||||
|             <ToggleButton value="All">All</ToggleButton> | ||||
|             <ToggleButton value="Inactive">Inactive</ToggleButton> | ||||
|           </ToggleButtonGroup> | ||||
|           <Button variant="contained" className="button-gold" startIcon={<AddRoundedIcon />} onClick={() => { setEditRow(null); setViewOnly(false); setOpen(true); }}> | ||||
|             Add Product Collection | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ flex: 1, minHeight: 0 }}> | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           disableRowSelectionOnClick | ||||
|           loading={loading || loadingTags} | ||||
|           pageSizeOptions={[50, 100, 200]} | ||||
|           initialState={{ | ||||
|             pagination: { paginationModel: { pageSize: 50 } }, | ||||
|             columns: { columnVisibilityModel: { id: false, _Id: false } }, | ||||
|           }} | ||||
|           getRowHeight={() => 'auto'} | ||||
|           columnBuffer={0} | ||||
|           sx={{ | ||||
|             height: '100%', | ||||
|             '& .MuiDataGrid-cell, & .MuiDataGrid-columnHeader': { | ||||
|               display: 'flex', | ||||
|               alignItems: 'center', | ||||
|             }, | ||||
|             '& .MuiDataGrid-filler': { | ||||
|               display: 'none', | ||||
|             }, | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ p: 2 }}>No product collection found. Try switching the status filter to "All".</Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </Box> | ||||
|  | ||||
|       <Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth> | ||||
|         <DialogTitle> | ||||
|           {viewOnly | ||||
|             ? 'View Product Collection' | ||||
|             : editRow | ||||
|               ? 'Edit Product Collection' | ||||
|               : 'Add Product Collection'} | ||||
|         </DialogTitle> | ||||
|         <DialogContent> | ||||
|           <AddOrEditProductCollectionForm | ||||
|             initialData={editRow} | ||||
|             viewOnly={viewOnly} | ||||
|             onAdd={(saved) => { | ||||
|               setOpen(false); | ||||
|               if (editRow) { | ||||
|                 setRawRows(prev => prev.map(r => (r.id === editRow.id ? { ...saved } : r))); | ||||
|               } else { | ||||
|                 setRawRows(prev => [{ ...saved, id: saved.id || saved._id || `row-${Date.now()}` }, ...prev]); | ||||
|               } | ||||
|             }} | ||||
|             onCancel={() => setOpen(false)} | ||||
|           /> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|  | ||||
|       <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}> | ||||
|         <DialogTitle>Delete Product Collection</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <Box sx={{ display: 'flex', gap: 1, mt: 2, mb: 1 }}> | ||||
|             <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|             <Button color="error" variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button> | ||||
|           </Box> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,64 +0,0 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { Box, Button, TextField, Typography, Paper } from '@mui/material'; | ||||
|  | ||||
| export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel }) { | ||||
|     const [category, setCategory] = useState({ | ||||
|         name: '', | ||||
|         description: '' | ||||
|     }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (initialData) { | ||||
|             setCategory(initialData); | ||||
|         } else { | ||||
|             setCategory({ name: '', description: '' }); | ||||
|         } | ||||
|     }, [initialData]); | ||||
|  | ||||
|     const handleChange = (e) => { | ||||
|         const { name, value } = e.target; | ||||
|         setCategory((prev) => ({ ...prev, [name]: value })); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmit = () => { | ||||
|         if (onAdd) { | ||||
|             onAdd(category); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ px: 2, py: 3 }}> | ||||
|             <Paper elevation={0} sx={{ p: 3, bgcolor: '#f9f9f9', borderRadius: 2 }}> | ||||
|                 <Typography variant="h6" gutterBottom> | ||||
|                     Category Details | ||||
|                 </Typography> | ||||
|                 <TextField | ||||
|                     fullWidth | ||||
|                     label="Name" | ||||
|                     name="name" | ||||
|                     value={category.name} | ||||
|                     onChange={handleChange} | ||||
|                     margin="normal" | ||||
|                 /> | ||||
|                 <TextField | ||||
|                     fullWidth | ||||
|                     label="Description" | ||||
|                     name="description" | ||||
|                     value={category.description} | ||||
|                     onChange={handleChange} | ||||
|                     margin="normal" | ||||
|                     multiline | ||||
|                     rows={4} | ||||
|                 /> | ||||
|                 <Box display="flex" justifyContent="flex-end" gap={1} mt={3}> | ||||
|                     <Button onClick={onCancel} className="button-transparent"> | ||||
|                         Cancel | ||||
|                     </Button> | ||||
|                     <Button variant="contained" onClick={handleSubmit} className="button-gold"> | ||||
|                         Save | ||||
|                     </Button> | ||||
|                 </Box> | ||||
|             </Paper> | ||||
|         </Box> | ||||
|     ); | ||||
| } | ||||
| @@ -1,142 +0,0 @@ | ||||
| import SectionContainer from '../../components/SectionContainer.jsx'; | ||||
| import { useState } from 'react'; | ||||
| import { DataGrid } from '@mui/x-data-grid'; | ||||
| import { Typography, Button, Dialog, DialogTitle, DialogContent, IconButton, Box } from '@mui/material'; | ||||
| import AddOrEditCategoryForm from './AddOrEditCategoryForm.jsx'; | ||||
|  | ||||
| import EditRoundedIcon from '@mui/icons-material/EditRounded'; | ||||
| import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; | ||||
| import '../../App.css'; | ||||
|  | ||||
| const columnsBase = [ | ||||
|     { field: 'name', headerName: 'Name', flex: 1 }, | ||||
|     { field: 'description', headerName: 'Description', flex: 2 } | ||||
| ]; | ||||
|  | ||||
| export default function Categories({ children, maxWidth = 'lg', sx = {} }) { | ||||
|     const [rows, setRows] = useState([ | ||||
|         { id: 1, name: 'Fabrics', description: 'Textile materials including silk, cotton, and synthetics.' }, | ||||
|         { id: 2, name: 'Leather Goods', description: 'Leather-based components for luxury goods.' }, | ||||
|         { id: 3, name: 'Metal Accessories', description: 'Buttons, zippers, and hardware in metal.' }, | ||||
|         { id: 4, name: 'Embellishments', description: 'Decorative materials such as beads and sequins.' } | ||||
|     ]); | ||||
|  | ||||
|     const [open, setOpen] = useState(false); | ||||
|     const [editingCategory, setEditingCategory] = useState(null); | ||||
|     const [confirmOpen, setConfirmOpen] = useState(false); | ||||
|     const [rowToDelete, setRowToDelete] = useState(null); | ||||
|  | ||||
|     const handleAddOrEditCategory = (category) => { | ||||
|         if (editingCategory) { | ||||
|             setRows(rows.map((row) => (row.id === editingCategory.id ? { ...editingCategory, ...category } : row))); | ||||
|         } else { | ||||
|             const id = rows.length + 1; | ||||
|             setRows([...rows, { id, ...category }]); | ||||
|         } | ||||
|         setOpen(false); | ||||
|         setEditingCategory(null); | ||||
|     }; | ||||
|  | ||||
|     const handleEditClick = (params) => { | ||||
|         setEditingCategory(params.row); | ||||
|         setOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleDeleteClick = (row) => { | ||||
|         setRowToDelete(row); | ||||
|         setConfirmOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const confirmDelete = () => { | ||||
|         setRows(rows.filter((row) => row.id !== rowToDelete.id)); | ||||
|         setRowToDelete(null); | ||||
|         setConfirmOpen(false); | ||||
|     }; | ||||
|  | ||||
|     const columns = [ | ||||
|         ...columnsBase, | ||||
|         { | ||||
|             field: 'actions', | ||||
|             headerName: '', | ||||
|             width: 130, | ||||
|             renderCell: (params) => ( | ||||
|                 <Box display="flex" alignItems="center" justifyContent="flex-end" height="100%" gap={2}> | ||||
|                     <IconButton | ||||
|                         size="small" | ||||
|                         sx={{ | ||||
|                             backgroundColor: '#DFCCBC', | ||||
|                             color: '#26201A', | ||||
|                             '&:hover': { | ||||
|                                 backgroundColor: '#C2B2A4', | ||||
|                             }, | ||||
|                             borderRadius: 2, | ||||
|                             p: 1, | ||||
|                         }} | ||||
|                         onClick={() => handleEditClick(params)} | ||||
|                     > | ||||
|                         <EditRoundedIcon fontSize="small" /> | ||||
|                     </IconButton> | ||||
|                     <IconButton | ||||
|                         size="small" | ||||
|                         sx={{ | ||||
|                             backgroundColor: '#FBE9E7', | ||||
|                             color: '#C62828', | ||||
|                             '&:hover': { | ||||
|                                 backgroundColor: '#EF9A9A', | ||||
|                             }, | ||||
|                             borderRadius: 2, | ||||
|                             p: 1, | ||||
|                         }} | ||||
|                         onClick={() => handleDeleteClick(params.row)} | ||||
|                     > | ||||
|                         <DeleteRoundedIcon fontSize="small" /> | ||||
|                     </IconButton> | ||||
|                 </Box> | ||||
|             ) | ||||
|         } | ||||
|     ]; | ||||
|  | ||||
|     return ( | ||||
|         <SectionContainer sx={{ width: '100%' }}> | ||||
|             <Typography variant="h4" gutterBottom color='#26201AFF'> | ||||
|                 Categories | ||||
|             </Typography> | ||||
|  | ||||
|             <Dialog open={open} onClose={() => { setOpen(false); setEditingCategory(null); }} fullWidth> | ||||
|                 <DialogTitle>{editingCategory ? 'Edit Category' : 'Add Category'}</DialogTitle> | ||||
|                 <DialogContent> | ||||
|                     <AddOrEditCategoryForm onAdd={handleAddOrEditCategory} initialData={editingCategory} onCancel={() => { setOpen(false); setEditingCategory(null); }} /> | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
|  | ||||
|             <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}> | ||||
|                 <DialogTitle>Confirm Delete</DialogTitle> | ||||
|                 <DialogContent> | ||||
|                     <Typography> | ||||
|                         Are you sure you want to delete <strong>{rowToDelete?.name}</strong>? | ||||
|                     </Typography> | ||||
|                     <Box mt={2} display="flex" justifyContent="flex-end" gap={1}> | ||||
|                         <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|                         <Button variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button> | ||||
|                     </Box> | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
|  | ||||
|             <Box mt={2}> | ||||
|                 <DataGrid | ||||
|                     rows={rows} | ||||
|                     columns={columns} | ||||
|                     pageSize={5} | ||||
|                     rowsPerPageOptions={[5]} | ||||
|                     getRowSpacing={() => ({ top: 8, bottom: 8 })} | ||||
|                 /> | ||||
|  | ||||
|                 <Box display="flex" justifyContent="flex-end" mt={2}> | ||||
|                     <Button variant="contained" onClick={() => setOpen(true)} className="button-gold"> | ||||
|                         Add Category | ||||
|                     </Button> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </SectionContainer> | ||||
|     ); | ||||
| } | ||||
| @@ -17,8 +17,6 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|     tenantId: '', | ||||
|     roleId: '', | ||||
|     status: 'Active', | ||||
|     companies: [], | ||||
|     projects: [], | ||||
|     sendInvitation: true, | ||||
|   }); | ||||
|  | ||||
| @@ -33,8 +31,6 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|         tenantId: initialData.tenantId ?? '', | ||||
|         roleId: initialData.roleId ?? '', | ||||
|         status: initialData.status ?? 'Active', | ||||
|         companies: Array.isArray(initialData.companies) ? initialData.companies : [], | ||||
|         projects: Array.isArray(initialData.projects) ? initialData.projects : [], | ||||
|         sendInvitation: true, | ||||
|       }); | ||||
|     } else { | ||||
| @@ -44,11 +40,9 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|         name: '', | ||||
|         middleName: '', | ||||
|         lastName: '', | ||||
|         tenantId: '', | ||||
|         roleId: '', | ||||
|         tenantId: '6894f9ddfb7072bdfc881613', | ||||
|         roleId: '68407642ec46a0e6fe1e8ec9', | ||||
|         status: 'Active', | ||||
|         companies: [], | ||||
|         projects: [], | ||||
|         sendInvitation: true, | ||||
|       }); | ||||
|     } | ||||
| @@ -56,12 +50,7 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|  | ||||
|   const handleChange = (e) => { | ||||
|     const { name, value } = e.target; | ||||
|     if (name === 'companies' || name === 'projects') { | ||||
|       const arr = value.split(',').map(s => s.trim()).filter(s => s.length > 0); | ||||
|       setFormData(prev => ({ ...prev, [name]: arr })); | ||||
|     } else { | ||||
|       setFormData(prev => ({ ...prev, [name]: value })); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
| @@ -78,8 +67,6 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|           lastName: formData.lastName, | ||||
|           tenantId: formData.tenantId, | ||||
|           roleId: formData.roleId, | ||||
|           companies: formData.companies, | ||||
|           projects: formData.projects, | ||||
|           status: formData.status || 'Active', | ||||
|         }; | ||||
|         await api.updateUser(payload); | ||||
| @@ -91,6 +78,7 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|           middleName: formData.middleName, | ||||
|           lastName: formData.lastName, | ||||
|           roleId: formData.roleId, | ||||
|           tenantId: formData.tenantId, | ||||
|           sendInvitation: !!formData.sendInvitation, | ||||
|         }; | ||||
|         await api.createUser(payload); | ||||
| @@ -143,6 +131,7 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|         value={formData.tenantId} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|         disabled={!initialData} | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
| @@ -151,24 +140,7 @@ export default function AddOrEditUserForm({ onAdd, initialData, onCancel }) { | ||||
|         value={formData.roleId} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Companies" | ||||
|         name="companies" | ||||
|         value={formData.companies.join(', ')} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|         helperText="Comma-separated list" | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|         label="Projects" | ||||
|         name="projects" | ||||
|         value={formData.projects.join(', ')} | ||||
|         onChange={handleChange} | ||||
|         margin="normal" | ||||
|         helperText="Comma-separated list" | ||||
|         disabled={!initialData} | ||||
|       /> | ||||
|       <TextField | ||||
|         fullWidth | ||||
|   | ||||
| @@ -119,14 +119,12 @@ export default function UserManagement() { | ||||
|  | ||||
|             const payload = { | ||||
|                 _Id: rowToDelete._id || rowToDelete._Id, | ||||
|                 Id: rowToDelete.id || rowToDelete.Id, | ||||
|                 email: rowToDelete.email ?? '', | ||||
|                 name: rowToDelete.name ?? '', | ||||
|                 middleName: rowToDelete.middleName ?? '', | ||||
|                 lastName: rowToDelete.lastName ?? '', | ||||
|                 roleId: rowToDelete.roleId ?? '', | ||||
|                 companies: Array.isArray(rowToDelete.companies) ? rowToDelete.companies : [], | ||||
|                 projects: Array.isArray(rowToDelete.projects) ? rowToDelete.projects : [], | ||||
|                 roleId: '68407642ec46a0e6fe1e8ec9', | ||||
|                 tenantId: '6894f9ddfb7072bdfc881613', | ||||
|                 status: 'Inactive', | ||||
|             }; | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,7 @@ const theme = createTheme({ | ||||
|             backgroundColor: '#f0eae3', | ||||
|           }, | ||||
|           '&.Mui-selected': { | ||||
|             backgroundColor: '#40120EFF', | ||||
|             backgroundColor: '#d0b9a8', | ||||
|             color: '#26201A', | ||||
|           }, | ||||
|           '&.Mui-selected:hover': { | ||||
| @@ -31,7 +31,7 @@ const theme = createTheme({ | ||||
|         }, | ||||
|         cell: { | ||||
|           '&:focus-within': { | ||||
|             outline: '2px solid #40120EFF', // custom Fendi focus | ||||
|             outline: '2px solid #d0b9a8', // custom Fendi focus | ||||
|             outlineOffset: '-2px', // tighten the outline | ||||
|             backgroundColor: '#f5f0eb', // optional subtle highlight | ||||
|           }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user