Compare commits
	
		
			36 Commits
		
	
	
		
			38626a3a81
			...
			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 | 
| 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 Footer from './components/Footer'; | ||||||
| import Dashboard from './private/dashboard/Dashboard'; | import Dashboard from './private/dashboard/Dashboard'; | ||||||
| import UserManagement from './private/users/UserManagement'; | 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 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'; | import { useAuth } from './context/AuthContext'; | ||||||
|  |  | ||||||
| const DRAWER_EXPANDED = OPEN_WIDTH; | const DRAWER_EXPANDED = OPEN_WIDTH; | ||||||
| const DRAWER_COLLAPSED = MINI_WIDTH; | const DRAWER_COLLAPSED = MINI_WIDTH; | ||||||
| const APPBAR_HEIGHT = 64; | const APPBAR_HEIGHT = 64; | ||||||
|  |  | ||||||
| function PrivateRoute({ children }) { |  | ||||||
|   const { user } = useAuth(); |  | ||||||
|   return user ? children : <Navigate to="/login" replace />; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export default function App() { | export default function App() { | ||||||
|   const theme = useTheme(); |   const theme = useTheme(); | ||||||
|   const isMobile = useMediaQuery('(max-width:900px)'); |   const isMobile = useMediaQuery('(max-width:900px)'); | ||||||
|   const [zone, setZone] = useState('public'); // public | restricted | private |  | ||||||
|  |  | ||||||
|   const [drawerExpanded, setDrawerExpanded] = useState(true); |   const [drawerExpanded, setDrawerExpanded] = useState(true); | ||||||
|   const [currentView, setCurrentView] = useState('Dashboard'); |   const [currentView, setCurrentView] = useState('Dashboard'); | ||||||
|  |   const { user, initializing } = useAuth(); | ||||||
|  |  | ||||||
|   const mainLeft = isMobile ? 0 : (drawerExpanded ? DRAWER_EXPANDED : DRAWER_COLLAPSED); |   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 ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <AppHeader |       <AppHeader zone="private" currentPage={currentView} leftOffset={mainLeft} /> | ||||||
|         zone="private" |  | ||||||
|         onSelectMenuItem={setCurrentView} |  | ||||||
|         drawerExpanded={drawerExpanded} |  | ||||||
|       /> |  | ||||||
|  |  | ||||||
|       <MenuDrawerPrivate |       <MenuDrawerPrivate | ||||||
|         onSelect={(value) => { |         onSelect={(value) => setCurrentView(value)} | ||||||
|           setCurrentView(value === '/users/UserManagement' ? 'UserManagement' : value); |  | ||||||
|         }} |  | ||||||
|         onExpandedChange={(expanded) => setDrawerExpanded(expanded)} |         onExpandedChange={(expanded) => setDrawerExpanded(expanded)} | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
| @@ -58,27 +58,27 @@ export default function App() { | |||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         <Routes> |         <Routes> | ||||||
|           <Route path="/login" element={<LoginPage />} /> |           <Route path="/login" element={<Navigate to="/" replace />} /> | ||||||
|  |  | ||||||
|           <Route |           <Route | ||||||
|             path="/" |             path="/" | ||||||
|             element={ |             element={ | ||||||
|               <PrivateRoute> |               <> | ||||||
|                 {zone === 'private' && <Clients />} |                 {currentView === 'Dashboard' && <Dashboard />} | ||||||
|                 {zone === 'restricted' && <Clients />} |                 {currentView === '/Users/UserManagement' && <UserManagement />} | ||||||
|  |                 {currentView === '/Products Management/Catalog Management/Product Collections' && ( | ||||||
|                 {zone === 'public' && currentView === 'Dashboard' && <Dashboard />} |                   <ProductCollections /> | ||||||
|  |                 )} | ||||||
|                 {zone === 'public' && currentView === 'UserManagement' && <UserManagement />} |                 {currentView === '/Products Management/Catalog Management/Categories' && ( | ||||||
|               </PrivateRoute> |                   <Categories /> | ||||||
|  |                 )} | ||||||
|  |               </> | ||||||
|             } |             } | ||||||
|           /> |           /> | ||||||
|         </Routes> |         </Routes> | ||||||
|       </Box> |       </Box> | ||||||
|  |  | ||||||
|       <Box sx={{ height: 64 }} /> |       <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 { | import { | ||||||
|     Drawer, List, ListItemButton, ListItemIcon, ListItemText, |     Drawer, List, ListItemButton, ListItemIcon, ListItemText, | ||||||
|     Collapse, IconButton, Tooltip, Box, useMediaQuery, InputBase |     Collapse, IconButton, Tooltip, Box, useMediaQuery, InputBase | ||||||
| @@ -40,11 +40,13 @@ const menuData = [ | |||||||
|                             { title: 'Categories' } |                             { title: 'Categories' } | ||||||
|                         ] |                         ] | ||||||
|                     }, |                     }, | ||||||
|                     { title: 'Products', |                     { | ||||||
|  |                         title: 'Products', | ||||||
|                         children: [ |                         children: [ | ||||||
|                             { title: 'AR  Assets Library Management' }, |                             { title: 'AR  Assets Library Management' }, | ||||||
|                             { title: 'Media Management' }, |                             { title: 'Media Management' }, | ||||||
|                     ] }, |                         ] | ||||||
|  |                     }, | ||||||
|                     { title: 'Product Collections' }, |                     { title: 'Product Collections' }, | ||||||
|                 ] |                 ] | ||||||
|             } |             } | ||||||
| @@ -54,7 +56,8 @@ const menuData = [ | |||||||
|         title: 'Customers', |         title: 'Customers', | ||||||
|         icon: <PeopleAltIcon />, |         icon: <PeopleAltIcon />, | ||||||
|         children: [ |         children: [ | ||||||
|             { title: 'CRM', |             { | ||||||
|  |                 title: 'CRM', | ||||||
|                 children: [ |                 children: [ | ||||||
|                     { title: 'Customer List' }, |                     { title: 'Customer List' }, | ||||||
|                     { title: 'Projects' }, |                     { title: 'Projects' }, | ||||||
| @@ -86,7 +89,8 @@ const menuData = [ | |||||||
|         icon: <AdminPanelSettingsIcon />, |         icon: <AdminPanelSettingsIcon />, | ||||||
|         children: [ |         children: [ | ||||||
|             { title: 'Users Management' }, |             { title: 'Users Management' }, | ||||||
|             { title: 'Access Control', |             { | ||||||
|  |                 title: 'Access Control', | ||||||
|                 children: [ |                 children: [ | ||||||
|                     { title: 'Roles' }, |                     { title: 'Roles' }, | ||||||
|                     { title: 'Permissions' }, |                     { title: 'Permissions' }, | ||||||
| @@ -154,7 +158,11 @@ export default function MenuDrawerPrivate({ | |||||||
|                                 handleToggleNode(key); |                                 handleToggleNode(key); | ||||||
|                             } else { |                             } else { | ||||||
|                                 if (node.title === 'Users Management') { |                                 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 { |                                 } else { | ||||||
|                                     onSelect?.(node.title); |                                     onSelect?.(node.title); | ||||||
|                                 } |                                 } | ||||||
| @@ -197,7 +205,7 @@ export default function MenuDrawerPrivate({ | |||||||
|                 {hasChildren && !collapsed && ( |                 {hasChildren && !collapsed && ( | ||||||
|                     <Collapse in={!!openMap[key]} timeout="auto" unmountOnExit> |                     <Collapse in={!!openMap[key]} timeout="auto" unmountOnExit> | ||||||
|                         <List component="div" disablePadding sx={{ pl: 7 }}> |                         <List component="div" disablePadding sx={{ pl: 7 }}> | ||||||
|                             {node.children.map((child, idx) => renderNode(child, `${key}-`))} |                             {node.children.map((child) => renderNode(child, `${key}-`))} | ||||||
|                         </List> |                         </List> | ||||||
|                     </Collapse> |                     </Collapse> | ||||||
|                 )} |                 )} | ||||||
|   | |||||||
| @@ -4,10 +4,12 @@ const AuthContext = createContext(); | |||||||
|  |  | ||||||
| export function AuthProvider({ children }) { | export function AuthProvider({ children }) { | ||||||
|   const [user, setUser] = useState(null); |   const [user, setUser] = useState(null); | ||||||
|  |   const [initializing, setInitializing] = useState(true); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const storedUser = localStorage.getItem('user'); |     const storedUser = localStorage.getItem('user'); | ||||||
|     if (storedUser) setUser(JSON.parse(storedUser)); |     if (storedUser) setUser(JSON.parse(storedUser)); | ||||||
|  |     setInitializing(false); | ||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|   const login = (userData) => { |   const login = (userData) => { | ||||||
| @@ -22,7 +24,7 @@ export function AuthProvider({ children }) { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <AuthContext.Provider value={{ user, login, logout }}> |     <AuthContext.Provider value={{ user, login, logout, initializing }}> | ||||||
|       {children} |       {children} | ||||||
|     </AuthContext.Provider> |     </AuthContext.Provider> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -15,8 +15,15 @@ export default function LoginPage() { | |||||||
|     return ( |     return ( | ||||||
|         <Box display="flex" justifyContent="center" alignItems="center" height="100vh"> |         <Box display="flex" justifyContent="center" alignItems="center" height="100vh"> | ||||||
|             <Paper sx={{ p: 4, borderRadius: 2, boxShadow: 3, textAlign: 'center' }}> |             <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 |                 <GoogleLogin | ||||||
|                     onSuccess={(cred) => { |                     onSuccess={(cred) => { | ||||||
|                         const idToken = cred?.credential; |                         const idToken = cred?.credential; | ||||||
| @@ -42,7 +49,7 @@ export default function LoginPage() { | |||||||
|                                 idToken: googleIdToken, |                                 idToken: googleIdToken, | ||||||
|                                 thalosToken: payload?.token || '', |                                 thalosToken: payload?.token || '', | ||||||
|                             }); |                             }); | ||||||
|                             navigate('/'); |                             navigate('/', { replace: true }); | ||||||
|                         }} |                         }} | ||||||
|                         onError={(err) => { |                         onError={(err) => { | ||||||
|                             console.error('Thalos exchange failed:', 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> |             </Paper> | ||||||
|         </Box> |         </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> |  | ||||||
|     ); |  | ||||||
| } |  | ||||||
		Reference in New Issue
	
	Block a user