Compare commits
	
		
			14 Commits
		
	
	
		
			d9bfaba977
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | f7adaf1b18 | ||
|   | b3209a4019 | ||
|   | efdb48919f | ||
|   | 6b8d5acc0d | ||
|   | 01a19b9144 | ||
|   | 74d6a8b269 | ||
|   | c33de6ada5 | ||
|   | 15107a48bd | ||
|   | 73699009fc | ||
|   | 55dc96085d | ||
|   | 9cdb76273d | ||
|   | 49dead566c | ||
|   | 2fa6b95012 | ||
|   | f5acde78de | 
| @@ -6,8 +6,8 @@ import MenuDrawerPrivate, { OPEN_WIDTH, MINI_WIDTH } from './components/MenuDraw | ||||
| import Footer from './components/Footer'; | ||||
| import Dashboard from './private/dashboard/Dashboard'; | ||||
| import UserManagement from './private/users/UserManagement'; | ||||
| import ProductCollections from './private/catalogs/ProductCollections'; | ||||
| import Categories from './private/categories/Categories'; | ||||
| import ProductCollections from './private/catalogs/products/ProductCollections'; | ||||
| import Categories from './private/catalogs/categories/Categories'; | ||||
| import LoginPage from './private/LoginPage'; | ||||
| import { Routes, Route, Navigate } from 'react-router-dom'; | ||||
| import { useAuth } from './context/AuthContext'; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| export default class FurnitureVariantApi { | ||||
| export default class ProductsApi { | ||||
|   constructor(token) { | ||||
|     this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/FurnitureVariant'; | ||||
|     this.token = token; | ||||
| @@ -52,4 +52,15 @@ export default class FurnitureVariantApi { | ||||
|     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(); | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import React, { useMemo, useState, useEffect } from 'react'; | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Drawer, List, ListItemButton, ListItemIcon, ListItemText, | ||||
|     Collapse, IconButton, Tooltip, Box, useMediaQuery, InputBase | ||||
|   | ||||
| @@ -1,355 +0,0 @@ | ||||
| // src/private/furniture/AddOrEditFurnitureVariantForm.jsx | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { Box, Button, TextField, MenuItem, Grid, CircularProgress } from '@mui/material'; | ||||
| import FurnitureVariantApi from '../../api/furnitureVariantApi'; | ||||
| 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 }) { | ||||
|   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> | ||||
|       <Grid container spacing={2}> | ||||
|         <Grid item xs={12} md={6}> | ||||
|           <TextField label="Model Id" fullWidth value={form.modelId} onChange={(e) => setVal('modelId', e.target.value)} /> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={6}> | ||||
|           <TextField label="Name" fullWidth value={form.name} onChange={(e) => setVal('name', e.target.value)} /> | ||||
|         </Grid> | ||||
|  | ||||
|         {/* Clasificación */} | ||||
|         <Grid item xs={12} md={6}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Category" | ||||
|             fullWidth | ||||
|             value={form.categoryId} | ||||
|             onChange={(e) => setVal('categoryId', e.target.value)} | ||||
|             helperText="Se envía el tagName por ahora" | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12} md={6}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Provider" | ||||
|             fullWidth | ||||
|             value={form.providerId} | ||||
|             onChange={(e) => setVal('providerId', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         {/* Específicos de variante */} | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Color" | ||||
|             fullWidth | ||||
|             value={form.color} | ||||
|             onChange={(e) => setVal('color', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Line" | ||||
|             fullWidth | ||||
|             value={form.line} | ||||
|             onChange={(e) => setVal('line', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Currency" | ||||
|             fullWidth | ||||
|             value={form.currency} | ||||
|             onChange={(e) => setVal('currency', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         {/* Atributos como catálogos */} | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Material" | ||||
|             fullWidth | ||||
|             value={form.attributes.material} | ||||
|             onChange={(e) => setVal('attributes.material', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Legs" | ||||
|             fullWidth | ||||
|             value={form.attributes.legs} | ||||
|             onChange={(e) => setVal('attributes.legs', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField | ||||
|             select | ||||
|             label="Origin" | ||||
|             fullWidth | ||||
|             value={form.attributes.origin} | ||||
|             onChange={(e) => setVal('attributes.origin', e.target.value)} | ||||
|           > | ||||
|             {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> | ||||
|         </Grid> | ||||
|  | ||||
|         {/* Números */} | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField label="Stock" type="number" fullWidth value={form.stock} onChange={(e) => setVal('stock', e.target.value)} /> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField label="Price" type="number" fullWidth value={form.price} onChange={(e) => setVal('price', e.target.value)} /> | ||||
|         </Grid> | ||||
|  | ||||
|         {/* Status */} | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <TextField select label="Status" fullWidth value={form.status} onChange={(e) => setVal('status', e.target.value)}> | ||||
|             <MenuItem value="Active">Active</MenuItem> | ||||
|             <MenuItem value="Inactive">Inactive</MenuItem> | ||||
|           </TextField> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|  | ||||
|       <Box display="flex" justifyContent="flex-end" mt={3} gap={1}> | ||||
|         <Button onClick={onCancel} className="button-transparent">Cancel</Button> | ||||
|         <Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,284 +0,0 @@ | ||||
| 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, FormControlLabel, Switch, Tooltip | ||||
| } 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 AddOrEditProductCollectionForm from './AddOrEditProductCollectionForm'; | ||||
| import FurnitureVariantApi from '../../api/furnitureVariantApi'; | ||||
| 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 toast = useApiToast(); | ||||
|  | ||||
|   const [rows, setRows] = useState([]); | ||||
|   const [rawRows, setRawRows] = useState([]); | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [editRow, setEditRow] = useState(null); | ||||
|   const [showInactive, setShowInactive] = useState(false); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   // 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); | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   const labelCategory = useMemo(() => buildLabelResolver(TYPE_NAMES.category), [byType]); | ||||
|   const labelProvider = useMemo(() => buildLabelResolver(TYPE_NAMES.provider), [byType]); | ||||
|   const labelColor = useMemo(() => buildLabelResolver(TYPE_NAMES.color), [byType]); | ||||
|   const labelLine = useMemo(() => buildLabelResolver(TYPE_NAMES.line), [byType]); | ||||
|   const labelCurrency = useMemo(() => buildLabelResolver(TYPE_NAMES.currency), [byType]); | ||||
|   const labelMaterial = useMemo(() => buildLabelResolver(TYPE_NAMES.material), [byType]); | ||||
|   const labelLegs = useMemo(() => buildLabelResolver(TYPE_NAMES.legs), [byType]); | ||||
|   const labelOrigin = useMemo(() => buildLabelResolver(TYPE_NAMES.origin), [byType]); | ||||
|  | ||||
|   // 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); | ||||
|       setRows(normalized.filter(r => showInactive ? true : r.status !== 'Inactive')); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|       toast.error(err?.message || 'Error loading variants'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { load(); /* eslint-disable-next-line */ }, []); | ||||
|   useEffect(() => { | ||||
|     setRows(rawRows.filter(r => showInactive ? true : r.status !== 'Inactive')); | ||||
|   }, [showInactive, rawRows]); | ||||
|  | ||||
|   const columns = [ | ||||
|     { field: 'modelId', headerName: 'Model Id', width: 220 }, | ||||
|     { field: 'name', headerName: 'Name', width: 200 }, | ||||
|     { field: 'categoryId', headerName: 'Category', width: 170, valueGetter: (p) => labelCategory(p?.row?.categoryId) }, | ||||
|     { field: 'providerId', headerName: 'Provider', width: 170, valueGetter: (p) => labelProvider(p?.row?.providerId) }, | ||||
|     { field: 'color', headerName: 'Color', width: 130, valueGetter: (p) => labelColor(p?.row?.color) }, | ||||
|     { field: 'line', headerName: 'Line', width: 130, valueGetter: (p) => labelLine(p?.row?.line) }, | ||||
|     { | ||||
|       field: 'price', | ||||
|       headerName: 'Price', | ||||
|       width: 130, | ||||
|       type: 'number', | ||||
|       valueGetter: (p) => parsePrice(p?.row?.price), | ||||
|       renderCell: (p) => { | ||||
|         const currency = labelCurrency(p?.row?.currency || 'USD'); | ||||
|         const val = parsePrice(p?.row?.price); | ||||
|         try { | ||||
|           return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(val); | ||||
|         } catch { | ||||
|           return `${currency} ${val.toFixed(2)}`; | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { field: 'currency', headerName: 'Currency', width: 120, valueGetter: (p) => labelCurrency(p?.row?.currency) }, | ||||
|     { field: 'stock', headerName: 'Stock', width: 100, type: 'number', valueGetter: (p) => Number(p?.row?.stock ?? 0) }, | ||||
|     { field: 'attributes.material', headerName: 'Material', width: 150, valueGetter: (p) => labelMaterial(p?.row?.attributes?.material) }, | ||||
|     { field: 'attributes.legs', headerName: 'Legs', width: 140, valueGetter: (p) => labelLegs(p?.row?.attributes?.legs) }, | ||||
|     { field: 'attributes.origin', headerName: 'Origin', width: 150, valueGetter: (p) => labelOrigin(p?.row?.attributes?.origin) }, | ||||
|     { field: 'status', headerName: 'Status', width: 120 }, | ||||
|     { | ||||
|       field: 'actions', | ||||
|       headerName: '', | ||||
|       sortable: false, | ||||
|       width: 110, | ||||
|       renderCell: (p) => ( | ||||
|         <Box display="flex" gap={1}> | ||||
|           <Tooltip title="Edit"> | ||||
|             <IconButton size="small" onClick={() => { setEditRow(p.row); setOpen(true); }}> | ||||
|               <EditRoundedIcon fontSize="small" /> | ||||
|             </IconButton> | ||||
|           </Tooltip> | ||||
|           <Tooltip title={p.row.status === 'Active' ? 'Deactivate' : 'Activate'}> | ||||
|             <IconButton | ||||
|               size="small" | ||||
|               onClick={async () => { | ||||
|                 try { | ||||
|                   const updated = { ...p.row, status: p.row.status === 'Active' ? 'Inactive' : 'Active' }; | ||||
|                   await api.updateVariant(updated); | ||||
|                   setRawRows(prev => prev.map(r => r.id === p.row.id ? updated : r)); | ||||
|                 } catch (err) { | ||||
|                   console.error(err); | ||||
|                   toast.error(err?.message || 'Error updating status'); | ||||
|                 } | ||||
|               }} | ||||
|             > | ||||
|               <DeleteRoundedIcon fontSize="small" /> | ||||
|             </IconButton> | ||||
|           </Tooltip> | ||||
|         </Box> | ||||
|       ) | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <Box display="flex" alignItems="center" justifyContent="space-between" mb={2}> | ||||
|         <Typography variant="h6">Furniture Variants</Typography> | ||||
|         <Box display="flex" alignItems="center" gap={2}> | ||||
|           <FormControlLabel control={<Switch checked={showInactive} onChange={(_, v) => setShowInactive(v)} />} label="Show Inactive" /> | ||||
|           <Button variant="contained" className="button-gold" startIcon={<AddRoundedIcon />} onClick={() => { setEditRow(null); setOpen(true); }}> | ||||
|             Add Variant | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ width: '100%', height: 560 }}> | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           disableRowSelectionOnClick | ||||
|           loading={loading || loadingTags} | ||||
|           pageSizeOptions={[10, 25, 50]} | ||||
|           initialState={{ | ||||
|             pagination: { paginationModel: { pageSize: 10 } }, | ||||
|             columns: { columnVisibilityModel: { id: false, _Id: false } }, | ||||
|           }} | ||||
|           getRowHeight={() => 'auto'} | ||||
|           sx={{ | ||||
|             '& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' }, | ||||
|             '& .MuiDataGrid-columnHeader': { display: 'flex', alignItems: 'center' }, | ||||
|           }} | ||||
|         /> | ||||
|       </Box> | ||||
|  | ||||
|       <Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth> | ||||
|         <DialogTitle>{editRow ? 'Edit Product Collection' : 'Add Product Collection'}</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <AddOrEditProductCollectionForm | ||||
|             initialData={editRow} | ||||
|             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> | ||||
|     </SectionContainer> | ||||
|   ); | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| 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 { useAuth } from '../../../context/AuthContext'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import TagTypeApi from '../../../api/TagTypeApi'; | ||||
| import { jwtDecode } from 'jwt-decode'; | ||||
| 
 | ||||
| function slugify(s) { | ||||
| @@ -43,7 +43,7 @@ function formatDateSafe(value) { | ||||
|   }).format(d); | ||||
| } | ||||
| 
 | ||||
| export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel, materials: materialsProp = [], initialMaterialNames = [] }) { | ||||
| 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]); | ||||
| @@ -52,17 +52,17 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel, ma | ||||
|   const [types, setTypes] = useState([]); | ||||
|   const [allTags, setAllTags] = useState([]); | ||||
| 
 | ||||
| const tagLabelById = useMemo(() => { | ||||
|   const tagLabelById = useMemo(() => { | ||||
|     const map = {}; | ||||
|     for (const t of allTags) { | ||||
|     const key = t._id || t.id; | ||||
|       const key = t._id; | ||||
|       map[key] = t.tagName || t.name || key; | ||||
|     } | ||||
|     return map; | ||||
| }, [allTags]); | ||||
|   }, [allTags]); | ||||
| 
 | ||||
|   const [form, setForm] = useState({ | ||||
|     _Id: '', | ||||
|     _id: '', | ||||
|     id: '', | ||||
|     tenantId: '', | ||||
|     tagName: '', | ||||
| @@ -107,9 +107,9 @@ const tagLabelById = useMemo(() => { | ||||
|     // Build a case-insensitive name -> id map | ||||
|     const nameToId = new Map( | ||||
|       allTags.map(t => { | ||||
|         const id = t._id || t.id; | ||||
|         const _id = t._id; | ||||
|         const label = (t.tagName || t.name || '').toLowerCase(); | ||||
|         return [label, id]; | ||||
|         return [label, _id]; | ||||
|       }) | ||||
|     ); | ||||
| 
 | ||||
| @@ -126,11 +126,9 @@ const tagLabelById = useMemo(() => { | ||||
|   // set inicial | ||||
|   useEffect(() => { | ||||
|     if (initialData) { | ||||
|       const _Id = initialData._id || initialData._Id || ''; | ||||
|       const id = initialData.id || initialData.Id || _Id || ''; | ||||
|       setForm({ | ||||
|         _Id, | ||||
|         id, | ||||
|         _id: initialData._id, | ||||
|         id: initialData.id, | ||||
|         tenantId: initialData.tenantId || extractTenantId(token) || '', | ||||
|         tagName: initialData.tagName || initialData.name || '', | ||||
|         typeId: initialData.typeId || '', | ||||
| @@ -146,7 +144,7 @@ const tagLabelById = useMemo(() => { | ||||
|       }); | ||||
|     } else { | ||||
|       setForm({ | ||||
|         _Id: '', | ||||
|         _id: '', | ||||
|         id: '', | ||||
|         tenantId: extractTenantId(token) || '', | ||||
|         tagName: '', | ||||
| @@ -164,7 +162,7 @@ const tagLabelById = useMemo(() => { | ||||
|     } | ||||
|   }, [initialData]); | ||||
| 
 | ||||
|   const isEdit = Boolean(form._Id || form.id); | ||||
|   const isEdit = Boolean(form._id); | ||||
|   const isAdd = !isEdit; | ||||
| 
 | ||||
|   const setVal = (name, value) => setForm(p => ({ ...p, [name]: value })); | ||||
| @@ -172,7 +170,7 @@ const tagLabelById = useMemo(() => { | ||||
|   const handleChange = (e) => { | ||||
|     const { name, value } = e.target; | ||||
|     setVal(name, value); | ||||
|     if (name === 'tagName' && !form._Id) { | ||||
|     if (name === 'tagName' && !form._id) { | ||||
|       setVal('slug', slugify(value)); | ||||
|     } | ||||
|   }; | ||||
| @@ -187,6 +185,7 @@ const tagLabelById = useMemo(() => { | ||||
|       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, | ||||
| @@ -197,15 +196,16 @@ const tagLabelById = useMemo(() => { | ||||
|         tenantId, // requerido por backend (400 si falta) | ||||
|       }; | ||||
| 
 | ||||
|       if (form._Id) { | ||||
|         // UPDATE | ||||
|       if (form._id) { | ||||
|         const idForUpdate = Boolean(form._id) ? String(form._id) : null; | ||||
|         if (!idForUpdate) throw new Error('Missing _id for update'); | ||||
|         const payload = { | ||||
|           id: form.id || form._Id, // backend acepta GUID; si no hay, mandamos _id | ||||
|           _id: idForUpdate, | ||||
|           ...base, | ||||
|         }; | ||||
|         console.log('[CategoryForm] SUBMIT (edit) with _id:', idForUpdate, 'payload:', payload); | ||||
|         await api.update(payload); | ||||
|       } else { | ||||
|         // CREATE | ||||
|         await api.create(base); | ||||
|       } | ||||
| 
 | ||||
| @@ -221,10 +221,9 @@ const tagLabelById = useMemo(() => { | ||||
| 
 | ||||
|   const handleDelete = async () => { | ||||
|     try { | ||||
|       // Try to use Mongo _Id (24-hex); if not present, fall back to GUID `id`. | ||||
|       const hex = typeof form._Id === 'string' && /^[0-9a-f]{24}$/i.test(form._Id) ? form._Id : null; | ||||
|       const idToUse = hex || form.id; | ||||
|       if (!idToUse) throw new Error('Missing id to delete'); | ||||
|       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(); | ||||
| @@ -238,7 +237,7 @@ const tagLabelById = useMemo(() => { | ||||
|   return ( | ||||
|     <Paper sx={{ p: 2 }}> | ||||
|       <Typography variant="subtitle1" sx={{ mb: 2 }}> | ||||
|         {form._Id ? 'Edit Category' : 'Add Category'} | ||||
|         {form._id ? 'Edit Category' : 'Add Category'} | ||||
|       </Typography> | ||||
| 
 | ||||
|       {isAdd && ( | ||||
| @@ -249,6 +248,7 @@ const tagLabelById = useMemo(() => { | ||||
|           fullWidth | ||||
|           sx={{ mb: 2 }} | ||||
|           InputProps={{ readOnly: true }} | ||||
|           disabled={viewOnly} | ||||
|         /> | ||||
|       )} | ||||
| 
 | ||||
| @@ -260,6 +260,7 @@ const tagLabelById = useMemo(() => { | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         required | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
| 
 | ||||
|       <TextField | ||||
| @@ -271,9 +272,10 @@ const tagLabelById = useMemo(() => { | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         required | ||||
|         disabled={viewOnly} | ||||
|       > | ||||
|         {types.map((t) => { | ||||
|           const value = t._id || t.id; // prefer Mongo _id for 1:1 mapping | ||||
|           const value = t._id; | ||||
|           const label = t.typeName || value; | ||||
|           return ( | ||||
|             <MenuItem key={value} value={value}> | ||||
| @@ -305,9 +307,10 @@ const tagLabelById = useMemo(() => { | ||||
|         }} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       > | ||||
|         {allTags.map((t) => { | ||||
|           const value = t._id || t.id; | ||||
|           const value = t._id; | ||||
|           const label = t.tagName || t.name || value; | ||||
|           return ( | ||||
|             <MenuItem key={value} value={value}> | ||||
| @@ -324,6 +327,7 @@ const tagLabelById = useMemo(() => { | ||||
|         onChange={handleChange} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
| 
 | ||||
|       <TextField | ||||
| @@ -334,6 +338,7 @@ const tagLabelById = useMemo(() => { | ||||
|         onChange={handleChange} | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
| 
 | ||||
|       <TextField | ||||
| @@ -344,6 +349,7 @@ const tagLabelById = useMemo(() => { | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         required | ||||
|         disabled={viewOnly} | ||||
|       /> | ||||
| 
 | ||||
|       <TextField | ||||
| @@ -354,12 +360,13 @@ const tagLabelById = useMemo(() => { | ||||
|         select | ||||
|         fullWidth | ||||
|         sx={{ mb: 2 }} | ||||
|         disabled={viewOnly} | ||||
|       > | ||||
|         <MenuItem value="Active">Active</MenuItem> | ||||
|         <MenuItem value="Inactive">Inactive</MenuItem> | ||||
|       </TextField> | ||||
| 
 | ||||
|       {form._Id || form.id ? ( | ||||
|       {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 /> | ||||
| @@ -369,12 +376,14 @@ const tagLabelById = useMemo(() => { | ||||
|       ) : null} | ||||
| 
 | ||||
|       <Box display="flex" justifyContent="space-between" gap={1} mt={3}> | ||||
|         {(form._Id || form.id) ? ( | ||||
|         {form._id && !viewOnly ? ( | ||||
|           <Button color="error" onClick={handleDelete}>Delete</Button> | ||||
|         ) : <span />} | ||||
|         <Box sx={{ display: 'flex', gap: 1 }}> | ||||
|           <Button onClick={onCancel} className="button-transparent">Cancel</Button> | ||||
|           <Button onClick={onCancel} className="button-transparent">{viewOnly ? 'Close' : 'Cancel'}</Button> | ||||
|           {!viewOnly && ( | ||||
|             <Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button> | ||||
|           )} | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Paper> | ||||
| @@ -6,10 +6,10 @@ import { | ||||
| 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'; | ||||
| import TagTypeApi from '../../api/TagTypeApi'; | ||||
| import CategoriesApi from '../../../api/CategoriesApi'; | ||||
| import { useAuth } from '../../../context/AuthContext'; | ||||
| 
 | ||||
| export default function Categories() { | ||||
|   const { user } = useAuth(); | ||||
| @@ -23,6 +23,7 @@ export default function Categories() { | ||||
|   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 | ||||
| @@ -41,21 +42,25 @@ export default function Categories() { | ||||
| 
 | ||||
|       setAllTags(list); | ||||
| 
 | ||||
|       // Build a map of parentId -> array of child tagNames | ||||
|       const parentToChildren = {}; | ||||
|       // Build a map of tagId -> tagName to resolve parent names | ||||
|       const idToName = {}; | ||||
|       for (const item of list) { | ||||
|         const parents = Array.isArray(item?.parentTagId) ? item.parentTagId : []; | ||||
|         for (const pid of parents) { | ||||
|           if (!parentToChildren[pid]) parentToChildren[pid] = []; | ||||
|           if (item?.tagName) parentToChildren[pid].push(item.tagName); | ||||
|         } | ||||
|         const key = item?._id || item?.id; | ||||
|         if (key) idToName[key] = item?.tagName || item?.name || ''; | ||||
|       } | ||||
| 
 | ||||
|       // Enrich each row with `material` (children whose parentTagId includes this _id) | ||||
|       const enriched = list.map((r) => ({ | ||||
|       // 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, | ||||
|         material: Array.isArray(parentToChildren[r?._id]) ? parentToChildren[r._id].join(', ') : '', | ||||
|       })); | ||||
|           materialNames, // array of strings | ||||
|         }; | ||||
|       }); | ||||
| 
 | ||||
|       setRows(enriched); | ||||
|     } catch (e) { | ||||
| @@ -65,16 +70,18 @@ export default function Categories() { | ||||
|   }; | ||||
| 
 | ||||
|   const handleAddClick = () => { | ||||
|     setViewOnly(false); | ||||
|     setEditingCategory(null); | ||||
|     setOpen(true); | ||||
|   }; | ||||
| 
 | ||||
|   const handleEditClick = (params) => { | ||||
|     setViewOnly(false); | ||||
|     const r = params?.row; | ||||
|     if (!r) return; | ||||
|     setEditingCategory({ | ||||
|       _Id: r._id || r._Id || '', | ||||
|       id: r.id || r.Id || '', | ||||
|       _id: String(r._id || ''), | ||||
|       id: String(r.id || ''), | ||||
|       tagName: r.tagName || r.name || '', | ||||
|       typeId: r.typeId || '', | ||||
|       parentTagId: Array.isArray(r.parentTagId) ? r.parentTagId : [], | ||||
| @@ -82,9 +89,11 @@ export default function Categories() { | ||||
|       displayOrder: Number(r.displayOrder ?? 0), | ||||
|       icon: r.icon || '', | ||||
|       status: r.status ?? 'Active', | ||||
|       materialNames: typeof r.material === 'string' | ||||
|       materialNames: Array.isArray(r.materialNames) | ||||
|         ? r.materialNames | ||||
|         : (typeof r.material === 'string' | ||||
|           ? r.material.split(',').map(s => s.trim()).filter(Boolean) | ||||
|         : Array.isArray(r.material) ? r.material : [], | ||||
|           : []), | ||||
|       createdAt: r.createdAt ?? null, | ||||
|       createdBy: r.createdBy ?? null, | ||||
|       updatedAt: r.updatedAt ?? null, | ||||
| @@ -100,7 +109,7 @@ export default function Categories() { | ||||
|   }; | ||||
| 
 | ||||
|   const pickHexId = (r) => | ||||
|     [r?._id, r?._Id, r?.id, r?.Id] | ||||
|     [r?._id, r?.id] | ||||
|       .filter(Boolean) | ||||
|       .find((x) => typeof x === 'string' && /^[0-9a-f]{24}$/i.test(x)) || null; | ||||
| 
 | ||||
| @@ -140,7 +149,7 @@ export default function Categories() { | ||||
|     { | ||||
|       field: 'actions', | ||||
|       headerName: '', | ||||
|       width: 130, | ||||
|       width: 150, | ||||
|       sortable: false, | ||||
|       filterable: false, | ||||
|       disableExport: true, | ||||
| @@ -159,6 +168,44 @@ export default function Categories() { | ||||
|           > | ||||
|             <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={{ | ||||
| @@ -178,7 +225,40 @@ export default function Categories() { | ||||
|     { 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', | ||||
| @@ -216,7 +296,7 @@ export default function Categories() { | ||||
|       }} | ||||
|     > | ||||
|       <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}> | ||||
|         <Typography variant="h6">Categories</Typography> | ||||
|         <Typography color='text.primary' variant="h6">Categories</Typography> | ||||
| 
 | ||||
|         <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> | ||||
|           <ToggleButtonGroup | ||||
| @@ -281,6 +361,7 @@ export default function Categories() { | ||||
|             initialMaterialNames={editingCategory?.materialNames || []} | ||||
|             onAdd={handleFormDone} | ||||
|             onCancel={() => { setOpen(false); setEditingCategory(null); }} | ||||
|             viewOnly={viewOnly} | ||||
|           /> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
| @@ -289,8 +370,8 @@ export default function Categories() { | ||||
|         <DialogTitle>Delete Category</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <Box sx={{ display: 'flex', gap: 1, mt: 2, mb: 1 }}> | ||||
|             <Button onClick={() => setConfirmOpen(false)}>Cancel</Button> | ||||
|             <Button color="error" variant="contained" onClick={confirmDelete}>Delete</Button> | ||||
|             <Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button> | ||||
|             <Button color="error" variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button> | ||||
|           </Box> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
							
								
								
									
										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> | ||||
|   ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user