feat: added dropdowns, categories and some filters

This commit is contained in:
2025-09-03 16:03:31 -06:00
parent cb27e16a10
commit 7e88f9ac4b
4 changed files with 606 additions and 394 deletions

View File

@@ -1,7 +1,8 @@
// src/api/CategoriesApi.js // src/api/CategoriesApi.js
export default class CategoriesApi { export default class CategoriesApi {
constructor(token) { constructor(token) {
this.root = 'https://inventory-bff.dream-views.com/api/v1'; // IMPORTANTE: singular "Tag", no "Tags"
this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/Tag';
this.token = token; this.token = token;
} }
@@ -13,65 +14,103 @@ export default class CategoriesApi {
}; };
} }
// ---- Tag ---- // Utilidad: validar ObjectId (24-hex) — el DAL lo exige para ChangeStatus
static isHex24(v) {
return typeof v === 'string' && /^[0-9a-fA-F]{24}$/.test(v);
}
// (Opcional) Utilidad: validar campos mínimos en create/update para evitar 400
static ensureFields(obj, fields) {
const missing = fields.filter((k) => obj[k] === undefined || obj[k] === null || obj[k] === '');
if (missing.length) {
throw new Error(`Missing required field(s): ${missing.join(', ')}`);
}
}
// GET /Tag/GetAll
async getAll() { async getAll() {
const res = await fetch(`${this.root}/Tag/GetAll`, { const res = await fetch(`${this.baseUrl}/GetAll`, {
method: 'GET', method: 'GET',
headers: this.headers(false), headers: this.headers(false),
}); });
if (!res.ok) throw new Error(`GetAll error ${res.status}: ${await res.text()}`); if (!res.ok) {
throw new Error(`GetAll error ${res.status}: ${await res.text()}`);
}
return res.json(); return res.json();
} }
// POST /Tag/Create
// payload esperado (min): tenantId, tagName, typeId(_id TagType), slug, displayOrder, icon, parentTagId([])
async create(payload) { async create(payload) {
const res = await fetch(`${this.root}/Tag/Create`, { // Validaciones básicas para evitar 400 comunes
CategoriesApi.ensureFields(payload, ['tenantId', 'tagName', 'typeId', 'icon']);
if (!Array.isArray(payload.parentTagId)) {
payload.parentTagId = [];
}
const res = await fetch(`${this.baseUrl}/Create`, {
method: 'POST', method: 'POST',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error(`Create error ${res.status}: ${await res.text()}`); if (!res.ok) {
throw new Error(`Create error ${res.status}: ${await res.text()}`);
}
return res.json(); return res.json();
} }
// PUT /Tag/Update
// payload esperado (min): id(GUID) ó _id(24-hex) según backend, + tenantId, tagName, typeId, icon, etc.
async update(payload) { async update(payload) {
const res = await fetch(`${this.root}/Tag/Update`, { CategoriesApi.ensureFields(payload, ['tenantId', 'tagName', 'typeId', 'icon']);
const res = await fetch(`${this.baseUrl}/Update`, {
method: 'PUT', method: 'PUT',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error(`Update error ${res.status}: ${await res.text()}`); if (!res.ok) {
throw new Error(`Update error ${res.status}: ${await res.text()}`);
}
return res.json(); return res.json();
} }
// PATCH ChangeStatus: body { id, status } // PATCH /Tag/ChangeStatus
// Debe mandarse { id: <_id 24-hex>, status: 'Active'|'Inactive' }
async changeStatus({ id, status }) { async changeStatus({ id, status }) {
const res = await fetch(`${this.root}/Tag/ChangeStatus`, { if (!CategoriesApi.isHex24(id)) {
// Evitar el 500 "String should contain only hexadecimal digits."
throw new Error('ChangeStatus requires a Mongo _id (24-hex) for "id".');
}
if (!status) {
throw new Error('ChangeStatus requires "status" field.');
}
const res = await fetch(`${this.baseUrl}/ChangeStatus`, {
method: 'PATCH', method: 'PATCH',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify({ id, status }), body: JSON.stringify({ id, status }),
}); });
if (!res.ok) throw new Error(`ChangeStatus error ${res.status}: ${await res.text()}`); if (!res.ok) {
return res.json?.() ?? null; throw new Error(`ChangeStatus error ${res.status}: ${await res.text()}`);
}
// Algunos endpoints devuelven vacío; devolvemos parsed o true
try {
return await res.json();
} catch {
return true;
}
} }
// DELETE /Tag/Delete (si lo usan; muchos usan soft-delete con ChangeStatus/Update)
async delete(payload) { async delete(payload) {
const res = await fetch(`${this.tagUrl}/Delete`, { const res = await fetch(`${this.baseUrl}/Delete`, {
method: 'DELETE', method: 'DELETE',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error(`Delete error ${res.status}: ${await res.text()}`); if (!res.ok) {
return res.json(); throw new Error(`Delete error ${res.status}: ${await res.text()}`);
} }
// ---- TagType ----
async getAllTypes() {
const res = await fetch(`${this.root}/TagType/GetAll`, {
method: 'GET',
headers: this.headers(false),
});
if (!res.ok) throw new Error(`TagType GetAll error ${res.status}: ${await res.text()}`);
return res.json(); return res.json();
} }
} }

26
src/api/TagTypeApi.js Normal file
View File

@@ -0,0 +1,26 @@
// src/api/TagTypeApi.js
export default class TagTypeApi {
constructor(token) {
this.baseUrl = 'https://inventory-bff.dream-views.com/api/v1/TagType';
this.token = token;
}
headers(json = true) {
return {
accept: 'application/json',
...(json ? { 'Content-Type': 'application/json' } : {}),
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
};
}
async getAll() {
const res = await fetch(`${this.baseUrl}/GetAll`, {
method: 'GET',
headers: this.headers(false),
});
if (!res.ok) {
throw new Error(`TagType.GetAll ${res.status}: ${await res.text()}`);
}
return res.json();
}
}

View File

@@ -1,20 +1,49 @@
// src/private/furniture/AddOrEditFurnitureVariantForm.jsx // src/private/furniture/AddOrEditFurnitureVariantForm.jsx
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Box, Button, TextField, MenuItem, Grid } from '@mui/material'; import { Box, Button, TextField, MenuItem, Grid, CircularProgress } from '@mui/material';
import FurnitureVariantApi from '../../api/furnitureVariantApi'; import FurnitureVariantApi from '../../api/furnitureVariantApi';
import CategoriesApi from '../../api/CategoriesApi';
import TagTypeApi from '../../api/TagTypeApi';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
const DEFAULT_MODEL_ID = '8a23117b-acaf-4d87-b64f-a98e9b414796'; 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 AddOrEditFurnitureVariantForm({ initialData, onAdd, onCancel }) { export default function AddOrEditFurnitureVariantForm({ initialData, onAdd, onCancel }) {
const { user } = useAuth(); const { user } = useAuth();
const token = user?.thalosToken || localStorage.getItem('thalosToken'); const token = user?.thalosToken || localStorage.getItem('thalosToken');
const api = useMemo(() => (new FurnitureVariantApi(token)), [token]);
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({ const [form, setForm] = useState({
_Id: '', _Id: '',
id: '', id: '',
modelId: '', modelId: DEFAULT_MODEL_ID,
name: '', name: '',
color: '', color: '',
line: '', line: '',
@@ -27,180 +56,290 @@ export default function AddOrEditFurnitureVariantForm({ initialData, onAdd, onCa
status: 'Active', status: 'Active',
}); });
useEffect(() => { const setVal = (path, value) => {
if (initialData) { if (path.startsWith('attributes.')) {
setForm({ const k = path.split('.')[1];
_Id: initialData._id || initialData._Id || '', setForm(prev => ({ ...prev, attributes: { ...prev.attributes, [k]: value } }));
id: initialData.id || initialData.Id || initialData._id || initialData._Id || '',
modelId: initialData.modelId ?? '',
name: initialData.name ?? '',
color: initialData.color ?? '',
line: initialData.line ?? '',
stock: initialData.stock ?? 0,
price: initialData.price ?? 0,
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',
});
} else { } else {
setForm({ setForm(prev => ({ ...prev, [path]: value }));
_Id: '',
id: '',
modelId: DEFAULT_MODEL_ID,
name: '',
color: '',
line: '',
stock: 0,
price: 0,
currency: 'USD',
categoryId: '',
providerId: '',
attributes: { material: '', legs: '', origin: '' },
status: 'Active',
});
} }
};
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]); }, [initialData]);
const setVal = (name, value) => setForm((p) => ({ ...p, [name]: value })); // Si viene GUID/_id/slug => convertir a tagName
const setAttr = (name, value) => setForm((p) => ({ ...p, attributes: { ...p.attributes, [name]: value } })); 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 () => { const handleSubmit = async () => {
try { try {
if (form._Id) { const payload = {
// UPDATE ...form,
const payload = { stock: Number(form.stock ?? 0),
Id: form.id || form._Id, // backend requires Id price: Number(form.price ?? 0),
_Id: form._Id, categoryId: form.categoryId || null, // enviamos tagName
modelId: form.modelId, providerId: form.providerId || null, // enviamos tagName
name: form.name, attributes: {
color: form.color, material: form.attributes.material || '',
line: form.line, legs: form.attributes.legs || '',
stock: Number(form.stock) || 0, origin: form.attributes.origin || '',
price: Number(form.price) || 0, }
currency: form.currency, };
categoryId: form.categoryId,
providerId: form.providerId, const isUpdate = Boolean(form.id || form._Id);
attributes: { const saved = isUpdate
material: form.attributes.material, ? await variantApi.updateVariant(payload)
legs: form.attributes.legs, : await variantApi.createVariant(payload);
origin: form.attributes.origin,
}, onAdd?.(saved);
status: form.status,
};
await api.updateVariant(payload);
} else {
// CREATE
const payload = {
modelId: form.modelId,
name: form.name,
color: form.color,
line: form.line,
stock: Number(form.stock) || 0,
price: Number(form.price) || 0,
currency: form.currency,
categoryId: form.categoryId,
providerId: form.providerId,
attributes: {
material: form.attributes.material,
legs: form.attributes.legs,
origin: form.attributes.origin,
},
status: form.status,
};
await api.createVariant(payload);
}
onAdd?.();
} catch (err) { } catch (err) {
console.error('Submit variant failed:', err); console.error(err);
alert(err?.message || 'Error saving variant');
} }
}; };
return ( return (
<Box sx={{ py: 2 }}> <Box>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<TextField <TextField label="Model Id" fullWidth value={form.modelId} onChange={(e) => setVal('modelId', e.target.value)} />
fullWidth
label="Model Id"
value={form.modelId}
onChange={(e) => setVal('modelId', e.target.value)}
disabled={!initialData}
helperText={!initialData ? 'Preset for new variant' : ''}
/>
</Grid> </Grid>
{form.id && (
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Id"
value={form.id}
disabled
helperText="Record identifier (read-only)"
/>
</Grid>
)}
{form._Id && (
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="_Id"
value={form._Id}
disabled
helperText="Mongo identifier (read-only)"
/>
</Grid>
)}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<TextField fullWidth label="Name" value={form.name} onChange={(e) => setVal('name', e.target.value)} /> <TextField label="Name" fullWidth value={form.name} onChange={(e) => setVal('name', e.target.value)} />
</Grid> </Grid>
<Grid item xs={12} md={4}> {/* Clasificación */}
<TextField fullWidth label="Color" value={form.color} onChange={(e) => setVal('color', e.target.value)} /> <Grid item xs={12} md={6}>
</Grid> <TextField
<Grid item xs={12} md={4}> select
<TextField fullWidth label="Line" value={form.line} onChange={(e) => setVal('line', e.target.value)} /> label="Category"
</Grid> fullWidth
<Grid item xs={12} md={2}> value={form.categoryId}
<TextField fullWidth type="number" label="Stock" value={form.stock} onChange={(e) => setVal('stock', e.target.value)} /> onChange={(e) => setVal('categoryId', e.target.value)}
</Grid> helperText="Se envía el tagName por ahora"
<Grid item xs={12} md={2}> >
<TextField fullWidth type="number" label="Price" value={form.price} onChange={(e) => setVal('price', e.target.value)} /> {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>
<Grid item xs={12} md={3}> <Grid item xs={12} md={6}>
<TextField fullWidth label="Currency" value={form.currency} onChange={(e) => setVal('currency', e.target.value)} /> <TextField
</Grid> select
<Grid item xs={12} md={4}> label="Provider"
<TextField fullWidth label="Category Id" value={form.categoryId} onChange={(e) => setVal('categoryId', e.target.value)} /> fullWidth
</Grid> value={form.providerId}
<Grid item xs={12} md={5}> onChange={(e) => setVal('providerId', e.target.value)}
<TextField fullWidth label="Provider Id" 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> </Grid>
{/* Específicos de variante */}
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<TextField fullWidth label="Material" value={form.attributes.material} onChange={(e) => setAttr('material', e.target.value)} /> <TextField
</Grid> select
<Grid item xs={12} md={4}> label="Color"
<TextField fullWidth label="Legs" value={form.attributes.legs} onChange={(e) => setAttr('legs', e.target.value)} /> fullWidth
</Grid> value={form.color}
<Grid item xs={12} md={4}> onChange={(e) => setVal('color', e.target.value)}
<TextField fullWidth label="Origin" value={form.attributes.origin} onChange={(e) => setAttr('origin', 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>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<TextField <TextField
fullWidth
select select
label="Status" label="Line"
value={form.status} fullWidth
onChange={(e) => setVal('status', e.target.value)} 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="Active">Active</MenuItem>
<MenuItem value="Inactive">Inactive</MenuItem> <MenuItem value="Inactive">Inactive</MenuItem>
</TextField> </TextField>

View File

@@ -1,277 +1,285 @@
// src/private/furniture/FurnitureVariantManagement.jsx
import SectionContainer from '../../components/SectionContainer'; import SectionContainer from '../../components/SectionContainer';
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { DataGrid } from '@mui/x-data-grid'; import { DataGrid } from '@mui/x-data-grid';
import { import {
Typography, Button, Dialog, DialogTitle, DialogContent, Typography, Button, Dialog, DialogTitle, DialogContent,
IconButton, Box, ToggleButton, ToggleButtonGroup IconButton, Box, FormControlLabel, Switch, Tooltip
} from '@mui/material'; } from '@mui/material';
import EditRoundedIcon from '@mui/icons-material/EditRounded'; import EditRoundedIcon from '@mui/icons-material/EditRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import AddOrEditFurnitureVariantForm from './AddOrEditFurnitureVariantForm'; import AddOrEditFurnitureVariantForm from './AddOrEditFurnitureVariantForm';
import FurnitureVariantApi from '../../api/furnitureVariantApi'; import FurnitureVariantApi from '../../api/furnitureVariantApi';
import CategoriesApi from '../../api/CategoriesApi';
import TagTypeApi from '../../api/TagTypeApi';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import useApiToast from '../../hooks/useApiToast'; import useApiToast from '../../hooks/useApiToast';
const columnsBase = [ const parsePrice = (p) => {
{ field: 'modelId', headerName: 'Model Id', width: 260 }, if (p == null) return 0;
{ field: 'name', headerName: 'Name', width: 220 }, if (typeof p === 'number') return p;
{ field: 'color', headerName: 'Color', width: 160 }, if (typeof p === 'string') return Number(p) || 0;
{ field: 'line', headerName: 'Line', width: 160 }, if (typeof p === 'object' && p.$numberDecimal) return Number(p.$numberDecimal) || 0;
{ field: 'stock', headerName: 'Stock', width: 100, type: 'number' }, return 0;
{ field: 'price', headerName: 'Price', width: 120, type: 'number', };
valueFormatter: (p) => p?.value != null ? Number(p.value).toFixed(2) : '—'
}, const TYPE_NAMES = {
{ field: 'currency', headerName: 'Currency', width: 120 }, category: 'Furniture category',
{ field: 'categoryId', headerName: 'Category Id', width: 280 }, provider: 'Provider',
{ field: 'providerId', headerName: 'Provider Id', width: 280 }, color: 'Color',
{ line: 'Line',
field: 'attributes.material', currency: 'Currency',
headerName: 'Material', material: 'Material',
width: 160, legs: 'Legs',
valueGetter: (p) => p?.row?.attributes?.material ?? '—' origin: 'Origin',
}, };
{
field: 'attributes.legs',
headerName: 'Legs',
width: 160,
valueGetter: (p) => p?.row?.attributes?.legs ?? '—'
},
{
field: 'attributes.origin',
headerName: 'Origin',
width: 160,
valueGetter: (p) => p?.row?.attributes?.origin ?? '—'
},
{ field: 'status', headerName: 'Status', width: 120 },
{
field: 'createdAt',
headerName: 'Created At',
width: 180,
valueFormatter: (p) => p?.value ? new Date(p.value).toLocaleString() : '—'
},
{ field: 'createdBy', headerName: 'Created By', width: 160, valueGetter: (p) => p?.row?.createdBy ?? '—' },
{
field: 'updatedAt',
headerName: 'Updated At',
width: 180,
valueFormatter: (p) => p?.value ? new Date(p.value).toLocaleString() : '—'
},
{ field: 'updatedBy', headerName: 'Updated By', width: 160, valueGetter: (p) => p?.row?.updatedBy ?? '—' },
];
export default function FurnitureVariantManagement() { export default function FurnitureVariantManagement() {
const { user } = useAuth(); const { user } = useAuth();
const token = user?.thalosToken || localStorage.getItem('thalosToken'); const token = user?.thalosToken || localStorage.getItem('thalosToken');
const apiRef = useRef(null);
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 [rows, setRows] = useState([]);
const [statusFilter, setStatusFilter] = useState('Active'); // <- por defecto Active const [rawRows, setRawRows] = useState([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingData, setEditingData] = useState(null); const [editRow, setEditRow] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [rowToDelete, setRowToDelete] = useState(null); const [loading, setLoading] = useState(true);
const { handleError } = useApiToast();
const hasLoaded = useRef(false);
useEffect(() => { // Tags
apiRef.current = new FurnitureVariantApi(token); const [loadingTags, setLoadingTags] = useState(true);
}, [token]); 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]: [],
});
useEffect(() => { const buildLabelResolver = (typeName) => {
if (!hasLoaded.current) { const list = byType[typeName] || [];
loadData(); return (value) => {
hasLoaded.current = true; 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 ||
const loadData = async () => { t._id === value ||
try { t._id?.$oid === value ||
const data = await apiRef.current.getAllVariants(); t.slug === value
setRows(Array.isArray(data) ? data : []); );
} catch (err) { return found?.tagName || String(value);
console.error('Error loading variants:', err);
handleError(err, 'Failed to load furniture variants');
setRows([]);
}
};
const handleEditClick = (params) => {
if (!params?.row) return;
const r = params.row;
const normalized = {
_id: r._id || r._Id || '',
_Id: r._id || r._Id || '',
id: r.id || r.Id || '',
modelId: r.modelId ?? '',
name: r.name ?? '',
color: r.color ?? '',
line: r.line ?? '',
stock: r.stock ?? 0,
price: r.price ?? 0,
currency: r.currency ?? 'USD',
categoryId: r.categoryId ?? '',
providerId: r.providerId ?? '',
attributes: {
material: r?.attributes?.material ?? '',
legs: r?.attributes?.legs ?? '',
origin: r?.attributes?.origin ?? '',
},
status: r.status ?? 'Active',
}; };
setEditingData(normalized);
setOpen(true);
}; };
const handleDeleteClick = (row) => { const labelCategory = useMemo(() => buildLabelResolver(TYPE_NAMES.category), [byType]);
setRowToDelete(row); const labelProvider = useMemo(() => buildLabelResolver(TYPE_NAMES.provider), [byType]);
setConfirmOpen(true); 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]);
const handleConfirmDelete = async () => { // 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 { try {
if (!apiRef.current || !(rowToDelete?._id || rowToDelete?._Id)) throw new Error('Missing API or id'); setLoading(true);
// Soft-delete vía Update (status=Inactive) const data = await api.getAllVariants();
const payload = { const normalized = (data || []).map((r, idx) => ({
id: rowToDelete.id || rowToDelete.Id || '', id: r.id || r._id || `row-${idx}`,
_Id: rowToDelete._id || rowToDelete._Id, _Id: r._id || r._Id || '',
modelId: rowToDelete.modelId, modelId: r.modelId ?? '',
name: rowToDelete.name, name: r.name ?? '',
color: rowToDelete.color, categoryId: r.categoryId ?? '',
line: rowToDelete.line, providerId: r.providerId ?? '',
stock: rowToDelete.stock, color: r.color ?? '',
price: rowToDelete.price, line: r.line ?? '',
currency: rowToDelete.currency, stock: Number(r.stock ?? 0),
categoryId: rowToDelete.categoryId, price: parsePrice(r.price),
providerId: rowToDelete.providerId, currency: r.currency ?? 'USD',
attributes: { attributes: {
material: rowToDelete?.attributes?.material ?? '', material: r?.attributes?.material ?? '',
legs: rowToDelete?.attributes?.legs ?? '', legs: r?.attributes?.legs ?? '',
origin: rowToDelete?.attributes?.origin ?? '', origin: r?.attributes?.origin ?? '',
}, },
status: 'Inactive', status: r.status ?? 'Active',
}; createdAt: r.createdAt ?? null,
await apiRef.current.updateVariant(payload); createdBy: r.createdBy ?? null,
await loadData(); }));
} catch (e) { setRawRows(normalized);
console.error('Delete failed:', e); setRows(normalized.filter(r => showInactive ? true : r.status !== 'Inactive'));
} catch (err) {
console.error(err);
toast.error(err?.message || 'Error loading variants');
} finally { } finally {
setConfirmOpen(false); setLoading(false);
setRowToDelete(null);
} }
}; };
// --- FILTRO DE ESTADO --- useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
const filteredRows = useMemo(() => { useEffect(() => {
if (statusFilter === 'All') return rows; setRows(rawRows.filter(r => showInactive ? true : r.status !== 'Inactive'));
const want = String(statusFilter).toLowerCase(); }, [showInactive, rawRows]);
return rows.filter((r) => String(r?.status ?? 'Active').toLowerCase() === want);
}, [rows, statusFilter]);
const columns = [ 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', field: 'actions',
headerName: '', headerName: '',
width: 130, sortable: false,
renderCell: (params) => ( width: 110,
<Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}> renderCell: (p) => (
<IconButton <Box display="flex" gap={1}>
size="small" <Tooltip title="Edit">
sx={{ <IconButton size="small" onClick={() => { setEditRow(p.row); setOpen(true); }}>
backgroundColor: '#DFCCBC', <EditRoundedIcon fontSize="small" />
color: '#26201A', </IconButton>
'&:hover': { backgroundColor: '#C2B2A4' }, </Tooltip>
borderRadius: 2, <Tooltip title={p.row.status === 'Active' ? 'Deactivate' : 'Activate'}>
p: 1, <IconButton
}} size="small"
onClick={() => handleEditClick(params)} onClick={async () => {
> try {
<EditRoundedIcon fontSize="small" /> const updated = { ...p.row, status: p.row.status === 'Active' ? 'Inactive' : 'Active' };
</IconButton> await api.updateVariant(updated);
<IconButton setRawRows(prev => prev.map(r => r.id === p.row.id ? updated : r));
size="small" } catch (err) {
sx={{ console.error(err);
backgroundColor: '#FBE9E7', toast.error(err?.message || 'Error updating status');
color: '#C62828', }
'&:hover': { backgroundColor: '#EF9A9A' }, }}
borderRadius: 2, >
p: 1, <DeleteRoundedIcon fontSize="small" />
}} </IconButton>
onClick={() => handleDeleteClick(params?.row)} </Tooltip>
>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Box> </Box>
) )
}, },
...columnsBase,
]; ];
return ( return (
<SectionContainer sx={{ width: '100%' }}> <SectionContainer>
<Dialog open={open} onClose={() => { setOpen(false); setEditingData(null); }} maxWidth="md" fullWidth> <Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<DialogTitle>{editingData ? 'Edit Product' : 'Add Product'}</DialogTitle> <Typography variant="h6">Furniture Variants</Typography>
<DialogContent> <Box display="flex" alignItems="center" gap={2}>
<AddOrEditFurnitureVariantForm <FormControlLabel control={<Switch checked={showInactive} onChange={(_, v) => setShowInactive(v)} />} label="Show Inactive" />
initialData={editingData} <Button variant="contained" className="button-gold" startIcon={<AddRoundedIcon />} onClick={() => { setEditRow(null); setOpen(true); }}>
onCancel={() => { setOpen(false); setEditingData(null); }} Add Variant
onAdd={async () => { </Button>
await loadData(); </Box>
setOpen(false);
setEditingData(null);
}}
/>
</DialogContent>
</Dialog>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete <strong>{rowToDelete?.name}</strong>?
</Typography>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button onClick={() => setConfirmOpen(false)} className="button-transparent">Cancel</Button>
<Button variant="contained" onClick={handleConfirmDelete} className="button-gold">Delete</Button>
</Box>
</DialogContent>
</Dialog>
{/* Toolbar de filtro */}
<Box mt={1} mb={1} display="flex" justifyContent="flex-end">
<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>
</Box> </Box>
<Box sx={{ width: '100%', overflowX: 'auto' }}> <Box sx={{ width: '100%', height: 560 }}>
<DataGrid <DataGrid
rows={filteredRows} rows={rows}
columns={columns} columns={columns}
pageSize={5} disableRowSelectionOnClick
rowsPerPageOptions={[5]} loading={loading || loadingTags}
getRowSpacing={() => ({ top: 4, bottom: 4 })} pageSizeOptions={[10, 25, 50]}
getRowId={(row) => row._id || row.id || row.modelId} initialState={{
autoHeight pagination: { paginationModel: { pageSize: 10 } },
disableColumnMenu columns: { columnVisibilityModel: { id: false, _Id: false } },
}}
getRowHeight={() => 'auto'} getRowHeight={() => 'auto'}
sx={{ sx={{
'& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' }, '& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' },
'& .MuiDataGrid-columnHeader': { display: 'flex', alignItems: 'center' }, '& .MuiDataGrid-columnHeader': { display: 'flex', alignItems: 'center' },
}} }}
/> />
<Box display="flex" justifyContent="flex-end" mt={2}>
<Button variant="contained" className="button-gold" onClick={() => setOpen(true)}>
Add Variant
</Button>
</Box>
</Box> </Box>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>{editRow ? 'Edit Variant' : 'Add Variant'}</DialogTitle>
<DialogContent>
<AddOrEditFurnitureVariantForm
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> </SectionContainer>
); );
} }