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
export default class CategoriesApi {
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;
}
@@ -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() {
const res = await fetch(`${this.root}/Tag/GetAll`, {
const res = await fetch(`${this.baseUrl}/GetAll`, {
method: 'GET',
headers: this.headers(false),
});
if (!res.ok) throw new Error(`GetAll error ${res.status}: ${await res.text()}`);
if (!res.ok) {
throw new Error(`GetAll error ${res.status}: ${await res.text()}`);
}
return res.json();
}
// POST /Tag/Create
// payload esperado (min): tenantId, tagName, typeId(_id TagType), slug, displayOrder, icon, parentTagId([])
async create(payload) {
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',
headers: this.headers(),
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();
}
// PUT /Tag/Update
// payload esperado (min): id(GUID) ó _id(24-hex) según backend, + tenantId, tagName, typeId, icon, etc.
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',
headers: this.headers(),
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();
}
// PATCH ChangeStatus: body { id, status }
// PATCH /Tag/ChangeStatus
// Debe mandarse { id: <_id 24-hex>, status: 'Active'|'Inactive' }
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',
headers: this.headers(),
body: JSON.stringify({ id, status }),
});
if (!res.ok) throw new Error(`ChangeStatus error ${res.status}: ${await res.text()}`);
return res.json?.() ?? null;
if (!res.ok) {
throw new Error(`ChangeStatus error ${res.status}: ${await res.text()}`);
}
// Algunos endpoints devuelven vacío; devolvemos parsed o true
try {
return await res.json();
} catch {
return true;
}
}
// DELETE /Tag/Delete (si lo usan; muchos usan soft-delete con ChangeStatus/Update)
async delete(payload) {
const res = await fetch(`${this.tagUrl}/Delete`, {
const res = await fetch(`${this.baseUrl}/Delete`, {
method: 'DELETE',
headers: this.headers(),
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Delete error ${res.status}: ${await res.text()}`);
return res.json();
}
// ---- 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()}`);
if (!res.ok) {
throw new Error(`Delete error ${res.status}: ${await res.text()}`);
}
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
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 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 AddOrEditFurnitureVariantForm({ initialData, onAdd, onCancel }) {
const { user } = useAuth();
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({
_Id: '',
id: '',
modelId: '',
modelId: DEFAULT_MODEL_ID,
name: '',
color: '',
line: '',
@@ -27,180 +56,290 @@ export default function AddOrEditFurnitureVariantForm({ initialData, onAdd, onCa
status: 'Active',
});
useEffect(() => {
if (initialData) {
setForm({
_Id: initialData._id || initialData._Id || '',
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',
});
const setVal = (path, value) => {
if (path.startsWith('attributes.')) {
const k = path.split('.')[1];
setForm(prev => ({ ...prev, attributes: { ...prev.attributes, [k]: value } }));
} else {
setForm({
_Id: '',
id: '',
modelId: DEFAULT_MODEL_ID,
name: '',
color: '',
line: '',
stock: 0,
price: 0,
currency: 'USD',
categoryId: '',
providerId: '',
attributes: { material: '', legs: '', origin: '' },
status: 'Active',
});
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]);
const setVal = (name, value) => setForm((p) => ({ ...p, [name]: value }));
const setAttr = (name, value) => setForm((p) => ({ ...p, attributes: { ...p.attributes, [name]: value } }));
// 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 {
if (form._Id) {
// UPDATE
const payload = {
Id: form.id || form._Id, // backend requires Id
_Id: form._Id,
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.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?.();
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('Submit variant failed:', err);
console.error(err);
alert(err?.message || 'Error saving variant');
}
};
return (
<Box sx={{ py: 2 }}>
<Box>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Model Id"
value={form.modelId}
onChange={(e) => setVal('modelId', e.target.value)}
disabled={!initialData}
helperText={!initialData ? 'Preset for new variant' : ''}
/>
<TextField label="Model Id" fullWidth value={form.modelId} onChange={(e) => setVal('modelId', e.target.value)} />
</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}>
<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 item xs={12} md={4}>
<TextField fullWidth label="Color" value={form.color} onChange={(e) => setVal('color', e.target.value)} />
</Grid>
<Grid item xs={12} md={4}>
<TextField fullWidth label="Line" value={form.line} onChange={(e) => setVal('line', e.target.value)} />
</Grid>
<Grid item xs={12} md={2}>
<TextField fullWidth type="number" label="Stock" value={form.stock} onChange={(e) => setVal('stock', e.target.value)} />
</Grid>
<Grid item xs={12} md={2}>
<TextField fullWidth type="number" label="Price" value={form.price} onChange={(e) => setVal('price', e.target.value)} />
{/* 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={3}>
<TextField fullWidth label="Currency" value={form.currency} onChange={(e) => setVal('currency', e.target.value)} />
</Grid>
<Grid item xs={12} md={4}>
<TextField fullWidth label="Category Id" value={form.categoryId} onChange={(e) => setVal('categoryId', e.target.value)} />
</Grid>
<Grid item xs={12} md={5}>
<TextField fullWidth label="Provider Id" value={form.providerId} onChange={(e) => setVal('providerId', e.target.value)} />
<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 fullWidth label="Material" value={form.attributes.material} onChange={(e) => setAttr('material', e.target.value)} />
</Grid>
<Grid item xs={12} md={4}>
<TextField fullWidth label="Legs" value={form.attributes.legs} onChange={(e) => setAttr('legs', e.target.value)} />
</Grid>
<Grid item xs={12} md={4}>
<TextField fullWidth label="Origin" value={form.attributes.origin} onChange={(e) => setAttr('origin', e.target.value)} />
<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
fullWidth
select
label="Status"
value={form.status}
onChange={(e) => setVal('status', e.target.value)}
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>
@@ -213,4 +352,4 @@ export default function AddOrEditFurnitureVariantForm({ initialData, onAdd, onCa
</Box>
</Box>
);
}
}

View File

@@ -1,277 +1,285 @@
// src/private/furniture/FurnitureVariantManagement.jsx
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 {
Typography, Button, Dialog, DialogTitle, DialogContent,
IconButton, Box, ToggleButton, ToggleButtonGroup
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 AddOrEditFurnitureVariantForm from './AddOrEditFurnitureVariantForm';
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 columnsBase = [
{ field: 'modelId', headerName: 'Model Id', width: 260 },
{ field: 'name', headerName: 'Name', width: 220 },
{ field: 'color', headerName: 'Color', width: 160 },
{ field: 'line', headerName: 'Line', width: 160 },
{ field: 'stock', headerName: 'Stock', width: 100, type: 'number' },
{ field: 'price', headerName: 'Price', width: 120, type: 'number',
valueFormatter: (p) => p?.value != null ? Number(p.value).toFixed(2) : '—'
},
{ field: 'currency', headerName: 'Currency', width: 120 },
{ field: 'categoryId', headerName: 'Category Id', width: 280 },
{ field: 'providerId', headerName: 'Provider Id', width: 280 },
{
field: 'attributes.material',
headerName: 'Material',
width: 160,
valueGetter: (p) => p?.row?.attributes?.material ?? '—'
},
{
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 ?? '—' },
];
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 FurnitureVariantManagement() {
const { user } = useAuth();
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 [statusFilter, setStatusFilter] = useState('Active'); // <- por defecto Active
const [rawRows, setRawRows] = useState([]);
const [open, setOpen] = useState(false);
const [editingData, setEditingData] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [rowToDelete, setRowToDelete] = useState(null);
const { handleError } = useApiToast();
const hasLoaded = useRef(false);
const [editRow, setEditRow] = useState(null);
const [showInactive, setShowInactive] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiRef.current = new FurnitureVariantApi(token);
}, [token]);
// 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]: [],
});
useEffect(() => {
if (!hasLoaded.current) {
loadData();
hasLoaded.current = true;
}
}, []);
const loadData = async () => {
try {
const data = await apiRef.current.getAllVariants();
setRows(Array.isArray(data) ? data : []);
} catch (err) {
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',
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);
};
setEditingData(normalized);
setOpen(true);
};
const handleDeleteClick = (row) => {
setRowToDelete(row);
setConfirmOpen(true);
};
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]);
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 {
if (!apiRef.current || !(rowToDelete?._id || rowToDelete?._Id)) throw new Error('Missing API or id');
// Soft-delete vía Update (status=Inactive)
const payload = {
id: rowToDelete.id || rowToDelete.Id || '',
_Id: rowToDelete._id || rowToDelete._Id,
modelId: rowToDelete.modelId,
name: rowToDelete.name,
color: rowToDelete.color,
line: rowToDelete.line,
stock: rowToDelete.stock,
price: rowToDelete.price,
currency: rowToDelete.currency,
categoryId: rowToDelete.categoryId,
providerId: rowToDelete.providerId,
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: rowToDelete?.attributes?.material ?? '',
legs: rowToDelete?.attributes?.legs ?? '',
origin: rowToDelete?.attributes?.origin ?? '',
material: r?.attributes?.material ?? '',
legs: r?.attributes?.legs ?? '',
origin: r?.attributes?.origin ?? '',
},
status: 'Inactive',
};
await apiRef.current.updateVariant(payload);
await loadData();
} catch (e) {
console.error('Delete failed:', e);
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 {
setConfirmOpen(false);
setRowToDelete(null);
setLoading(false);
}
};
// --- FILTRO DE ESTADO ---
const filteredRows = useMemo(() => {
if (statusFilter === 'All') return rows;
const want = String(statusFilter).toLowerCase();
return rows.filter((r) => String(r?.status ?? 'Active').toLowerCase() === want);
}, [rows, statusFilter]);
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: '',
width: 130,
renderCell: (params) => (
<Box display="flex" alignItems="center" justifyContent="flex-start" height="100%" gap={1}>
<IconButton
size="small"
sx={{
backgroundColor: '#DFCCBC',
color: '#26201A',
'&:hover': { backgroundColor: '#C2B2A4' },
borderRadius: 2,
p: 1,
}}
onClick={() => handleEditClick(params)}
>
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
sx={{
backgroundColor: '#FBE9E7',
color: '#C62828',
'&:hover': { backgroundColor: '#EF9A9A' },
borderRadius: 2,
p: 1,
}}
onClick={() => handleDeleteClick(params?.row)}
>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
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>
)
},
...columnsBase,
];
return (
<SectionContainer sx={{ width: '100%' }}>
<Dialog open={open} onClose={() => { setOpen(false); setEditingData(null); }} maxWidth="md" fullWidth>
<DialogTitle>{editingData ? 'Edit Product' : 'Add Product'}</DialogTitle>
<DialogContent>
<AddOrEditFurnitureVariantForm
initialData={editingData}
onCancel={() => { setOpen(false); setEditingData(null); }}
onAdd={async () => {
await loadData();
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>
<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%', overflowX: 'auto' }}>
<Box sx={{ width: '100%', height: 560 }}>
<DataGrid
rows={filteredRows}
rows={rows}
columns={columns}
pageSize={5}
rowsPerPageOptions={[5]}
getRowSpacing={() => ({ top: 4, bottom: 4 })}
getRowId={(row) => row._id || row.id || row.modelId}
autoHeight
disableColumnMenu
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 display="flex" justifyContent="flex-end" mt={2}>
<Button variant="contained" className="button-gold" onClick={() => setOpen(true)}>
Add Variant
</Button>
</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>
);
}