Compare commits

...

18 Commits

Author SHA1 Message Date
Rodolfo Ruiz
f7adaf1b18 chore: clean code, add delete 2025-09-06 20:45:56 -06:00
Rodolfo Ruiz
b3209a4019 chore: delete implementation in product collection 2025-09-06 20:34:46 -06:00
Rodolfo Ruiz
efdb48919f chore: add the logic to see in view mode the categories as well 2025-09-06 20:08:39 -06:00
Rodolfo Ruiz
6b8d5acc0d chore: add the view button 2025-09-06 20:04:38 -06:00
Rodolfo Ruiz
01a19b9144 chore: improve the ui for Products 2025-09-06 19:53:10 -06:00
Rodolfo Ruiz
74d6a8b269 chore: fix the edit popup 2025-09-06 19:48:21 -06:00
Rodolfo Ruiz
c33de6ada5 chore: show all the columns from db 2025-09-06 19:19:40 -06:00
Rodolfo Ruiz
15107a48bd chore: show all the fields from the getAll 2025-09-06 18:52:45 -06:00
Rodolfo Ruiz
73699009fc chore: fix products columns 2025-09-06 18:44:42 -06:00
Rodolfo Ruiz
55dc96085d chore: add filters 2025-09-06 18:34:09 -06:00
Rodolfo Ruiz
9cdb76273d chore: organize names and folders to better fit 2025-09-06 18:30:32 -06:00
Rodolfo Ruiz
49dead566c chore: update, delete and create 2025-09-05 19:17:17 -06:00
Rodolfo Ruiz
2fa6b95012 chore: create items 2025-09-05 18:50:11 -06:00
Rodolfo Ruiz
f5acde78de chore: show material in the grid 2025-09-05 18:06:32 -06:00
Rodolfo Ruiz
d9bfaba977 chore: show material in the edit form, needs to test save button 2025-09-04 21:35:26 -06:00
Rodolfo Ruiz
f42d08c091 chore: show dates 2025-09-04 21:25:30 -06:00
Rodolfo Ruiz
aa62b06c23 chore: load TagType and materials, show in the gridview and the edit 2025-09-04 21:16:00 -06:00
Rodolfo Ruiz
d699af9d75 chore: hide dates and by columns 2025-09-04 20:51:46 -06:00
18 changed files with 1049 additions and 738 deletions

View File

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

View File

@@ -1,7 +1,7 @@
export default class FurnitureVariantApi {
export default class ProductsApi {
constructor(token) {
this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/FurnitureVariant';
this.token = token;
this.token = token;
}
headers(json = true) {
@@ -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();
}
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
// src/private/categories/AddOrEditCategoryForm.jsx
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 { useAuth } from '../../../context/AuthContext';
import CategoriesApi from '../../../api/CategoriesApi';
import TagTypeApi from '../../../api/TagTypeApi';
import { jwtDecode } from 'jwt-decode';
function slugify(s) {
@@ -18,25 +18,51 @@ function extractTenantId(token) {
const payload = jwtDecode(token);
const t = payload?.tenant;
if (Array.isArray(t)) {
// prefer a 24-hex string if present
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 {}
} catch { }
return '';
}
export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel }) {
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: '',
id: '',
tenantId: '',
tagName: '',
@@ -52,27 +78,57 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
updatedBy: null,
});
// cargar tipos y tags para selects
// cargar tipos (Tag Types) y tags para selects
useEffect(() => {
(async () => {
try {
const [t, tags] = await Promise.all([api.getAllTypes(), api.getAll()]);
setTypes(Array.isArray(t) ? t : []);
// 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([]);
}
})();
}, [api]);
}, [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) {
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 || '',
@@ -88,7 +144,7 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
});
} else {
setForm({
_Id: '',
_id: '',
id: '',
tenantId: extractTenantId(token) || '',
tagName: '',
@@ -106,12 +162,15 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
}
}, [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) {
if (name === 'tagName' && !form._id) {
setVal('slug', slugify(value));
}
};
@@ -126,6 +185,7 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
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,
@@ -136,15 +196,16 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
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);
}
@@ -160,10 +221,9 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
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();
@@ -177,26 +237,20 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
return (
<Paper sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
{form._Id ? 'Edit Category' : 'Add Category'}
{form._id ? 'Edit Category' : 'Add Category'}
</Typography>
{/* IDs (read-only) */}
{form._Id || form.id ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mb: 2 }}>
<TextField label="_Id" value={form._Id} InputProps={{ readOnly: true }} fullWidth />
<TextField label="id" value={form.id} InputProps={{ readOnly: true }} fullWidth />
</Box>
) : null}
{/* Tenant (read-only; comes from token or existing record) */}
<TextField
name="tenantId"
label="Tenant Id"
value={form.tenantId}
fullWidth
sx={{ mb: 2 }}
InputProps={{ readOnly: true }}
/>
{isAdd && (
<TextField
name="tenantId"
label="Tenant Id"
value={form.tenantId}
fullWidth
sx={{ mb: 2 }}
InputProps={{ readOnly: true }}
disabled={viewOnly}
/>
)}
<TextField
name="tagName"
@@ -206,46 +260,63 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
fullWidth
sx={{ mb: 2 }}
required
disabled={viewOnly}
/>
<TextField
name="typeId"
label="Type"
label="Category"
value={form.typeId}
onChange={handleChange}
select
fullWidth
sx={{ mb: 2 }}
required
disabled={viewOnly}
>
{types.map(t => (
<MenuItem key={t.id || t._id} value={t.id || t._id}>
{t.typeName} ({t.level ?? '-'})
</MenuItem>
))}
{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="Parent tags"
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', typeof val === 'string' ? val.split(',').map(s => s.trim()).filter(Boolean) : val);
setVal('parentTagId', Array.isArray(val) ? val : []);
}}
select
SelectProps={{ multiple: true, renderValue: (selected) => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map(v => <Chip key={v} label={v} size="small" />)}
</Box>
)}}
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 || t.id;
{allTags.map((t) => {
const value = t._id;
const label = t.tagName || t.name || value;
return <MenuItem key={value} value={value}>{label}</MenuItem>;
return (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
);
})}
</TextField>
@@ -256,6 +327,7 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
onChange={handleChange}
fullWidth
sx={{ mb: 2 }}
disabled={viewOnly}
/>
<TextField
@@ -266,6 +338,7 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
onChange={handleChange}
fullWidth
sx={{ mb: 2 }}
disabled={viewOnly}
/>
<TextField
@@ -276,6 +349,7 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
fullWidth
sx={{ mb: 2 }}
required
disabled={viewOnly}
/>
<TextField
@@ -286,27 +360,30 @@ export default function AddOrEditCategoryForm({ onAdd, initialData, onCancel })
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={form.createdAt ? new Date(form.createdAt).toLocaleString() : '—'} InputProps={{ readOnly: true }} fullWidth />
<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={form.updatedAt ? new Date(form.updatedAt).toLocaleString() : '—'} 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 || form.id) ? (
{form._id && !viewOnly ? (
<Button color="error" onClick={handleDelete}>Delete</Button>
) : <span /> }
) : <span />}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={onCancel} className="button-transparent">Cancel</Button>
<Button variant="contained" onClick={handleSubmit} className="button-gold">Save</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>

View File

@@ -1,12 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography,
ToggleButton, ToggleButtonGroup } from '@mui/material';
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';
import CategoriesApi from '../../../api/CategoriesApi';
import { useAuth } from '../../../context/AuthContext';
export default function Categories() {
const { user } = useAuth();
@@ -14,11 +17,13 @@ export default function Categories() {
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
@@ -35,21 +40,27 @@ export default function Categories() {
const data = await api.getAll();
const list = Array.isArray(data) ? data : [];
// Build a map of parentId -> array of child tagNames
const parentToChildren = {};
setAllTags(list);
// 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) => ({
...r,
material: Array.isArray(parentToChildren[r?._id]) ? parentToChildren[r._id].join(', ') : '',
}));
// 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) {
@@ -59,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 : [],
@@ -76,6 +89,15 @@ export default function Categories() {
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);
};
@@ -87,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;
@@ -127,7 +149,7 @@ export default function Categories() {
{
field: 'actions',
headerName: '',
width: 130,
width: 150,
sortable: false,
filterable: false,
disableExport: true,
@@ -146,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={{
@@ -164,10 +224,41 @@ 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: 120 },
// New computed column
{ field: 'icon', headerName: 'Icon', flex: 0.7, minWidth: 250 },
/*
{ field: 'material', headerName: 'Material', flex: 1.2, minWidth: 200 },
// Hidden audit columns
*/
{
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',
@@ -205,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
@@ -229,7 +320,17 @@ export default function Categories() {
<DataGrid
rows={filteredRows}
columns={columns}
initialState={{ pagination: { paginationModel: { pageSize: pageSize } } }}
initialState={{
pagination: { paginationModel: { pageSize } },
columns: {
columnVisibilityModel: {
createdAt: false,
createdBy: false,
updatedAt: false,
updatedBy: false,
},
},
}}
pageSizeOptions={[pageSize]}
disableColumnMenu
getRowId={(r) => r?._id || r?.id}
@@ -256,8 +357,11 @@ export default function Categories() {
<DialogContent>
<AddOrEditCategoryForm
initialData={editingCategory}
allTags={allTags}
initialMaterialNames={editingCategory?.materialNames || []}
onAdd={handleFormDone}
onCancel={() => { setOpen(false); setEditingCategory(null); }}
viewOnly={viewOnly}
/>
</DialogContent>
</Dialog>
@@ -266,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>

View File

@@ -0,0 +1,361 @@
// src/private/furniture/AddOrEditFurnitureVariantForm.jsx
import { useEffect, useMemo, useState } from 'react';
import { Box, Button, TextField, MenuItem, CircularProgress } from '@mui/material';
import FurnitureVariantApi from '../../../api/ProductsApi';
import CategoriesApi from '../../../api/CategoriesApi';
import TagTypeApi from '../../../api/TagTypeApi';
import { useAuth } from '../../../context/AuthContext';
const DEFAULT_MODEL_ID = '8a23117b-acaf-4d87-b64f-a98e9b414796';
const TYPE_NAMES = {
category: 'Furniture category',
provider: 'Provider',
color: 'Color',
line: 'Line',
currency: 'Currency',
material: 'Material',
legs: 'Legs',
origin: 'Origin',
};
export default function AddOrEditProductCollectionForm({ initialData, onAdd, onCancel, viewOnly = false }) {
const { user } = useAuth();
const token = user?.thalosToken || localStorage.getItem('thalosToken');
const variantApi = useMemo(() => new FurnitureVariantApi(token), [token]);
const tagTypeApi = useMemo(() => new TagTypeApi(token), [token]);
const categoriesApi = useMemo(() => new CategoriesApi(token), [token]);
const [loadingTags, setLoadingTags] = useState(true);
const [typeMap, setTypeMap] = useState({});
const [options, setOptions] = useState({
categories: [],
providers: [],
colors: [],
lines: [],
currencies: [],
materials: [],
legs: [],
origins: [],
});
const [form, setForm] = useState({
_Id: '',
id: '',
modelId: DEFAULT_MODEL_ID,
name: '',
color: '',
line: '',
stock: 0,
price: 0,
currency: 'USD',
categoryId: '',
providerId: '',
attributes: { material: '', legs: '', origin: '' },
status: 'Active',
});
const setVal = (path, value) => {
if (path.startsWith('attributes.')) {
const k = path.split('.')[1];
setForm(prev => ({ ...prev, attributes: { ...prev.attributes, [k]: value } }));
} else {
setForm(prev => ({ ...prev, [path]: value }));
}
};
const parsePrice = (p) => {
if (p == null) return 0;
if (typeof p === 'number') return p;
if (typeof p === 'string') return Number(p) || 0;
if (typeof p === 'object' && p.$numberDecimal) return Number(p.$numberDecimal) || 0;
return 0;
};
// Cargar TagTypes + Tags
useEffect(() => {
let mounted = true;
(async () => {
try {
const [types, tags] = await Promise.all([tagTypeApi.getAll(), categoriesApi.getAll()]);
const tmap = {};
types?.forEach(t => {
if (!t?.typeName || !t?._id) return;
tmap[t.typeName] = t._id;
});
const byType = (tname) => {
const tid = tmap[tname];
if (!tid) return [];
return (tags || [])
.filter(tag => tag?.typeId === tid)
.map(tag => ({ ...tag }));
};
if (mounted) {
setTypeMap(tmap);
setOptions({
categories: byType(TYPE_NAMES.category),
providers: byType(TYPE_NAMES.provider),
colors: byType(TYPE_NAMES.color),
lines: byType(TYPE_NAMES.line),
currencies: byType(TYPE_NAMES.currency),
materials: byType(TYPE_NAMES.material),
legs: byType(TYPE_NAMES.legs),
origins: byType(TYPE_NAMES.origin),
});
}
} catch (err) {
console.error('Failed loading TagTypes/Tags', err);
} finally {
if (mounted) setLoadingTags(false);
}
})();
return () => { mounted = false; };
}, [tagTypeApi, categoriesApi]);
// Sembrar datos al editar
useEffect(() => {
if (!initialData) return;
setForm({
_Id: initialData._Id ?? initialData._id ?? '',
id: initialData.id ?? '',
modelId: initialData.modelId ?? DEFAULT_MODEL_ID,
name: initialData.name ?? '',
color: initialData.color ?? '',
line: initialData.line ?? '',
stock: Number(initialData.stock ?? 0),
price: parsePrice(initialData.price),
currency: initialData.currency ?? 'USD',
categoryId: initialData.categoryId ?? '',
providerId: initialData.providerId ?? '',
attributes: {
material: initialData?.attributes?.material ?? '',
legs: initialData?.attributes?.legs ?? '',
origin: initialData?.attributes?.origin ?? '',
},
status: initialData.status ?? 'Active',
});
}, [initialData]);
// Si viene GUID/_id/slug => convertir a tagName
useEffect(() => {
if (loadingTags) return;
const toTagNameIfNeeded = (value, list) => {
if (!value) return '';
if (list.some(t => t.tagName === value)) return value; // ya es tagName
const found = list.find(t => t.id === value || t._id === value || t._id?.$oid === value || t.slug === value);
return found?.tagName || value;
};
setForm(prev => ({
...prev,
categoryId: toTagNameIfNeeded(prev.categoryId, options.categories),
providerId: toTagNameIfNeeded(prev.providerId, options.providers),
color: toTagNameIfNeeded(prev.color, options.colors),
line: toTagNameIfNeeded(prev.line, options.lines),
currency: toTagNameIfNeeded(prev.currency, options.currencies),
attributes: {
...prev.attributes,
material: toTagNameIfNeeded(prev.attributes?.material, options.materials),
legs: toTagNameIfNeeded(prev.attributes?.legs, options.legs),
origin: toTagNameIfNeeded(prev.attributes?.origin, options.origins),
}
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadingTags, options]);
const handleSubmit = async () => {
try {
const payload = {
...form,
stock: Number(form.stock ?? 0),
price: Number(form.price ?? 0),
categoryId: form.categoryId || null, // enviamos tagName
providerId: form.providerId || null, // enviamos tagName
attributes: {
material: form.attributes.material || '',
legs: form.attributes.legs || '',
origin: form.attributes.origin || '',
}
};
const isUpdate = Boolean(form.id || form._Id);
const saved = isUpdate
? await variantApi.updateVariant(payload)
: await variantApi.createVariant(payload);
onAdd?.(saved);
} catch (err) {
console.error(err);
alert(err?.message || 'Error saving variant');
}
};
return (
<Box>
<Box display="flex" flexDirection="column" gap={2}>
{/* Name */}
<TextField
label="Name"
fullWidth
value={form.name}
onChange={(e) => setVal('name', e.target.value)}
sx={{ mt: 1 }}
disabled={viewOnly}
/>
{/* Category / Provider */}
<TextField
select
label="Category"
fullWidth
value={form.categoryId}
onChange={(e) => setVal('categoryId', e.target.value)}
helperText="Se envía el tagName por ahora"
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.categories.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
<TextField
select
label="Provider"
fullWidth
value={form.providerId}
onChange={(e) => setVal('providerId', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.providers.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
<Box display="flex" gap={2}>
{/* Color */}
<Box flex={1}>
<TextField
select
label="Color"
fullWidth
value={form.color}
onChange={(e) => setVal('color', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.colors.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
</Box>
{/* Line */}
<Box flex={1}>
<TextField
select
label="Line"
fullWidth
value={form.line}
onChange={(e) => setVal('line', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.lines.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
</Box>
{/* Material */}
<Box flex={1}>
<TextField
select
label="Material"
fullWidth
value={form.attributes.material}
onChange={(e) => setVal('attributes.material', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.materials.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
</Box>
</Box>
<Box display="flex" gap={2}>
{/* Price */}
<Box flex={1}>
<TextField label="Price" type="number" fullWidth value={form.price} onChange={(e) => setVal('price', e.target.value)} disabled={viewOnly} />
</Box>
{/* Stock */}
<Box flex={1}>
<TextField label="Stock" type="number" fullWidth value={form.stock} onChange={(e) => setVal('stock', e.target.value)} disabled={viewOnly} />
</Box>
{/* Currency */}
<Box flex={1}>
<TextField
select
label="Currency"
fullWidth
value={form.currency}
onChange={(e) => setVal('currency', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.currencies.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
</Box>
</Box>
{/* Attributes */}
<TextField
select
label="Legs"
fullWidth
value={form.attributes.legs}
onChange={(e) => setVal('attributes.legs', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.legs.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
<TextField
select
label="Origin"
fullWidth
value={form.attributes.origin}
onChange={(e) => setVal('attributes.origin', e.target.value)}
disabled={viewOnly}
>
{loadingTags && <MenuItem value="" disabled><CircularProgress size={16} /> Cargando</MenuItem>}
{!loadingTags && options.origins.filter(t => t.status !== 'Inactive').map(tag => (
<MenuItem key={tag._id?.$oid || tag._id || tag.id} value={tag.tagName}>{tag.tagName}</MenuItem>
))}
</TextField>
{/* Status */}
<TextField select label="Status" fullWidth value={form.status} onChange={(e) => setVal('status', e.target.value)} disabled={viewOnly}>
<MenuItem value="Active">Active</MenuItem>
<MenuItem value="Inactive">Inactive</MenuItem>
</TextField>
</Box>
<Box display="flex" justifyContent="flex-end" mt={3} gap={1}>
<Button onClick={onCancel} className="button-transparent">{viewOnly ? 'Close' : 'Cancel'}</Button>
{!viewOnly && (
<Button variant="contained" onClick={handleSubmit} className="button-gold">Save</Button>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,397 @@
import SectionContainer from '../../../components/SectionContainer';
import { useEffect, useMemo, useState } from 'react';
import { DataGrid } from '@mui/x-data-grid';
import {
Typography, Button, Dialog, DialogTitle, DialogContent,
IconButton, Box, ToggleButtonGroup, ToggleButton
} from '@mui/material';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
import AddOrEditProductCollectionForm from './AddOrEditProductCollectionForm';
import FurnitureVariantApi from '../../../api/ProductsApi';
import CategoriesApi from '../../../api/CategoriesApi';
import TagTypeApi from '../../../api/TagTypeApi';
import { useAuth } from '../../../context/AuthContext';
import useApiToast from '../../../hooks/useApiToast';
const parsePrice = (p) => {
if (p == null) return 0;
if (typeof p === 'number') return p;
if (typeof p === 'string') return Number(p) || 0;
if (typeof p === 'object' && p.$numberDecimal) return Number(p.$numberDecimal) || 0;
return 0;
};
const TYPE_NAMES = {
category: 'Furniture category',
provider: 'Provider',
color: 'Color',
line: 'Line',
currency: 'Currency',
material: 'Material',
legs: 'Legs',
origin: 'Origin',
};
export default function ProductCollections() {
const { user } = useAuth();
const token = user?.thalosToken || localStorage.getItem('thalosToken');
const api = useMemo(() => new FurnitureVariantApi(token), [token]);
const tagTypeApi = useMemo(() => new TagTypeApi(token), [token]);
const categoriesApi = useMemo(() => new CategoriesApi(token), [token]);
const { handleError } = useApiToast();
const [rows, setRows] = useState([]);
const [rawRows, setRawRows] = useState([]);
const [open, setOpen] = useState(false);
const [editRow, setEditRow] = useState(null);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('All');
const [viewOnly, setViewOnly] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
// Tags
const [loadingTags, setLoadingTags] = useState(true);
const [typeMap, setTypeMap] = useState({});
const [byType, setByType] = useState({
[TYPE_NAMES.category]: [],
[TYPE_NAMES.provider]: [],
[TYPE_NAMES.color]: [],
[TYPE_NAMES.line]: [],
[TYPE_NAMES.currency]: [],
[TYPE_NAMES.material]: [],
[TYPE_NAMES.legs]: [],
[TYPE_NAMES.origin]: [],
});
const buildLabelResolver = (typeName) => {
const list = byType[typeName] || [];
return (value) => {
if (!value && value !== 0) return '—';
if (list.some(t => t.tagName === value)) return value; // ya es tagName
const found = list.find(t =>
t.id === value ||
t._id === value ||
t._id?.$oid === value ||
t.slug === value
);
return found?.tagName || String(value);
};
};
// Cargar TagTypes + Tags
useEffect(() => {
let mounted = true;
(async () => {
try {
const [types, tags] = await Promise.all([tagTypeApi.getAll(), categoriesApi.getAll()]);
const tmap = {};
types?.forEach(t => {
if (!t?.typeName || !t?._id) return;
tmap[t.typeName] = t._id;
});
const group = (tname) => {
const tid = tmap[tname];
if (!tid) return [];
return (tags || []).filter(tag => tag?.typeId === tid);
};
if (mounted) {
setTypeMap(tmap);
setByType({
[TYPE_NAMES.category]: group(TYPE_NAMES.category),
[TYPE_NAMES.provider]: group(TYPE_NAMES.provider),
[TYPE_NAMES.color]: group(TYPE_NAMES.color),
[TYPE_NAMES.line]: group(TYPE_NAMES.line),
[TYPE_NAMES.currency]: group(TYPE_NAMES.currency),
[TYPE_NAMES.material]: group(TYPE_NAMES.material),
[TYPE_NAMES.legs]: group(TYPE_NAMES.legs),
[TYPE_NAMES.origin]: group(TYPE_NAMES.origin),
});
}
} catch (e) {
console.error('Failed loading TagTypes/Tags', e);
} finally {
if (mounted) setLoadingTags(false);
}
})();
return () => { mounted = false; };
}, [tagTypeApi, categoriesApi]);
// Cargar variants
const load = async () => {
try {
setLoading(true);
const data = await api.getAllVariants();
const normalized = (data || []).map((r, idx) => ({
id: r.id || r._id || `row-${idx}`,
_Id: r._id || r._Id || '',
modelId: r.modelId ?? '',
name: r.name ?? '',
categoryId: r.categoryId ?? '',
providerId: r.providerId ?? '',
color: r.color ?? '',
line: r.line ?? '',
stock: Number(r.stock ?? 0),
price: parsePrice(r.price),
currency: r.currency ?? 'USD',
attributes: {
material: r?.attributes?.material ?? '',
legs: r?.attributes?.legs ?? '',
origin: r?.attributes?.origin ?? '',
},
status: r.status ?? 'Active',
createdAt: r.createdAt ?? null,
createdBy: r.createdBy ?? null,
}));
setRawRows(normalized);
} catch (err) {
console.error(err);
handleError(err, 'Error loading product collections');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
useEffect(() => {
if (statusFilter === 'All') {
setRows(rawRows);
} else {
const want = statusFilter.toLowerCase();
setRows(rawRows.filter(r => String(r.status ?? 'Active').toLowerCase() === want));
}
}, [statusFilter, rawRows]);
const handleDeleteClick = (row) => {
setEditRow(row);
setConfirmOpen(true);
};
const confirmDelete = async () => {
try {
if (!editRow?.id) return;
await api.changeStatusVariant({ mongoId: editRow._Id, status: 'Inactive' });
await load();
} catch (err) {
console.error(err);
} finally {
setConfirmOpen(false);
setEditRow(null);
}
};
const columns = [
{
field: 'actions',
headerName: '',
width: 190,
sortable: false,
filterable: false,
disableExport: true,
renderCell: (params) => (
<Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}>
<IconButton
size="small"
sx={{
backgroundColor: '#DFCCBC',
color: '#26201A',
'&:hover': { backgroundColor: '#C2B2A4' },
borderRadius: 2,
p: 1,
}}
onClick={() => { setEditRow(params.row); setViewOnly(false); setOpen(true); }}
>
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
sx={{
backgroundColor: '#E3F2FD',
color: '#1565C0',
'&:hover': { backgroundColor: '#BBDEFB' },
borderRadius: 2,
p: 1,
}}
onClick={() => { setEditRow(params.row); setOpen(true); setViewOnly(true); }}
>
<VisibilityRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
sx={{
backgroundColor: '#FBE9E7',
color: '#C62828',
'&:hover': { backgroundColor: '#EF9A9A' },
borderRadius: 2,
p: 1,
}}
onClick={() => { setViewOnly(false); handleDeleteClick(params?.row); }}
>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Box>
),
},
{ field: 'name', headerName: 'Name', flex: 1, minWidth: 160 },
{ field: 'categoryId', headerName: 'Category', flex: 1, minWidth: 160 },
{ field: 'providerId', headerName: 'Provider', flex: 1, minWidth: 160 },
{ field: 'color', headerName: 'Color', flex: 1, minWidth: 160 },
{ field: 'line', headerName: 'Line', flex: 1, minWidth: 160 },
{
field: 'price',
headerName: 'Price',
flex: 1,
minWidth: 160,
type: 'number',
valueGetter: (p) => parsePrice(p?.row?.price),
renderCell: (p) => {
const val = parsePrice(p?.row?.price);
const currency = p?.row?.currency || 'USD';
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(val);
} catch {
return `${currency} ${val.toFixed(2)}`;
}
}
},
{ field: 'currency', headerName: 'Currency', flex: 1, minWidth: 160 },
{ field: 'stock', headerName: 'Stock', flex: 1, minWidth: 160 },
{
field: 'attributes',
headerName: 'Attributes',
minWidth: 300,
flex: 1.5,
sortable: false,
filterable: false,
renderCell: (params) => {
const a = params?.row?.attributes || {};
const chips = Object.entries(a).filter(([, v]) => !!v);
return (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{chips.map(([key, value]) => (
<Box
key={key}
component="span"
sx={{
px: 1.5,
py: 0.5,
borderRadius: '12px',
backgroundColor: '#DFCCBC',
fontSize: 12,
color: '#26201A',
lineHeight: '18px',
}}
>
{key}: {value}
</Box>
))}
</Box>
);
}
},
{ field: 'status', headerName: 'Status', flex: 1, minWidth: 160 }
];
return (
<Box
sx={{
height: 'calc(100vh - 64px - 64px)',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={2}>
<Typography color='text.primary' variant="h6">Product Collection</Typography>
<Box display="flex" alignItems="center" gap={2}>
<ToggleButtonGroup
value={statusFilter}
exclusive
onChange={(_, v) => v && setStatusFilter(v)}
size="small"
>
<ToggleButton value="Active">Active</ToggleButton>
<ToggleButton value="All">All</ToggleButton>
<ToggleButton value="Inactive">Inactive</ToggleButton>
</ToggleButtonGroup>
<Button variant="contained" className="button-gold" startIcon={<AddRoundedIcon />} onClick={() => { setEditRow(null); setViewOnly(false); setOpen(true); }}>
Add Product Collection
</Button>
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>
<DataGrid
rows={rows}
columns={columns}
disableRowSelectionOnClick
loading={loading || loadingTags}
pageSizeOptions={[50, 100, 200]}
initialState={{
pagination: { paginationModel: { pageSize: 50 } },
columns: { columnVisibilityModel: { id: false, _Id: false } },
}}
getRowHeight={() => 'auto'}
columnBuffer={0}
sx={{
height: '100%',
'& .MuiDataGrid-cell, & .MuiDataGrid-columnHeader': {
display: 'flex',
alignItems: 'center',
},
'& .MuiDataGrid-filler': {
display: 'none',
},
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ p: 2 }}>No product collection found. Try switching the status filter to "All".</Box>
),
}}
/>
</Box>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
{viewOnly
? 'View Product Collection'
: editRow
? 'Edit Product Collection'
: 'Add Product Collection'}
</DialogTitle>
<DialogContent>
<AddOrEditProductCollectionForm
initialData={editRow}
viewOnly={viewOnly}
onAdd={(saved) => {
setOpen(false);
if (editRow) {
setRawRows(prev => prev.map(r => (r.id === editRow.id ? { ...saved } : r)));
} else {
setRawRows(prev => [{ ...saved, id: saved.id || saved._id || `row-${Date.now()}` }, ...prev]);
}
}}
onCancel={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Delete Product Collection</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', gap: 1, mt: 2, mb: 1 }}>
<Button onClick={() => setConfirmOpen(false)} className='button-transparent'>Cancel</Button>
<Button color="error" variant="contained" onClick={confirmDelete} className="button-gold">Delete</Button>
</Box>
</DialogContent>
</Dialog>
</Box>
);
}

View File

@@ -102,7 +102,7 @@ export default function UserManagement() {
status: r.status ?? 'Active',
companies: Array.isArray(r.companies) ? r.companies : [],
projects: Array.isArray(r.projects) ? r.projects : [],
};
};
setEditingData(normalized);
setOpen(true);
};