-
Notifications
You must be signed in to change notification settings - Fork 0
filtros y busquedas aplicadas #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds comprehensive filtering and search capabilities to the inventory management page. The implementation includes a collapsible filter panel with price range, stock range, and category filters, along with a search bar for filtering products by name.
Key Changes:
- Added search functionality with real-time filtering by product name
- Implemented advanced filters for price range, stock quantity, and category selection
- Introduced a toggleable filter panel UI with responsive design and improved styling
Comments suppressed due to low confidence (1)
apigrupo11/frontend/src/app/inventario/page.tsx:128
- The filter logic doesn't handle the case where
minPrice > maxPriceorminStock > maxStock. Users could set minPrice to 1000 and maxPrice to 100, which would result in no products being displayed without clear feedback about the invalid range.
Consider adding validation:
const filteredProductos = productos.filter((producto) => {
const matchesSearch = producto.nombre
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Swap if min > max
const actualMinPrice = Math.min(appliedFilters.minPrice, appliedFilters.maxPrice);
const actualMaxPrice = Math.max(appliedFilters.minPrice, appliedFilters.maxPrice);
const matchesPrice =
producto.precio >= actualMinPrice &&
producto.precio <= actualMaxPrice;
// Similar for stock...
const matchesStock = /* ... */;
const matchesCategory = /* ... */;
return matchesSearch && matchesPrice && matchesStock && matchesCategory;
});Or prevent invalid ranges in the handleApplyFilters function.
const handleUpdateProduct = (updatedProduct: IProducto) => {
setProductos((prev) => prev.map((p) => (p.id === updatedProduct.id ? updatedProduct : p)));
setShowAddForm(false);
setEditingProduct(null);
};
const handleEditClick = (product: IProducto) => {
setEditingProduct(product);
setShowAddForm(true);
};
const handleCloseForm = () => {
setShowAddForm(false);
setEditingProduct(null);
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| margin: 0, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| }} | ||
| > | ||
| <MdFilterList size={20} /> | ||
| Filtrar productos | ||
| </h3> | ||
| <p | ||
| style={{ | ||
| fontSize: 'clamp(12px, 3vw, 13px)', | ||
| color: theme.colors.textSecondary, | ||
| margin: '4px 0 0 0', | ||
| }} | ||
| > | ||
| Ajusta los filtros y presiona Aplicar | ||
| </p> | ||
| </div> | ||
|
|
||
| {/* Grid de filtros - responsive */} | ||
| <div | ||
| style={{ | ||
| display: 'grid', | ||
| gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', | ||
| gap: 'clamp(16px, 3%, 24px)', | ||
| marginBottom: '24px', | ||
| }} | ||
| > | ||
| {/* Rango de Precio */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdAttachMoney size={18} /> | ||
| Rango de Precio | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| ${filters.minPrice} - ${filters.maxPrice} | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="$0" | ||
| step="1" | ||
| value={filters.minPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minPrice: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="$10000" | ||
| step="1" | ||
| value={filters.maxPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxPrice: | ||
| e.target.value === '' | ||
| ? 10000 | ||
| : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Rango de Cantidad */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdInventory2 size={18} /> | ||
| Rango de Cantidad | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| {filters.minStock} - {filters.maxStock} unidades | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="0" | ||
| step="1" | ||
| value={filters.minStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minStock: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="10000" | ||
| step="1" | ||
| value={filters.maxStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxStock: | ||
| e.target.value === '' | ||
| ? 10000 | ||
| : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Categoría */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdCategory size={18} /> | ||
| Categoría | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| {filters.categoria || 'Todas las categorías'} | ||
| </p> | ||
| <select | ||
| value={filters.categoria} | ||
| onChange={(e) => | ||
| setFilters({ ...filters, categoria: e.target.value }) | ||
| } | ||
| style={{ |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filter inputs (price range, stock range) and select dropdown are missing associated id attributes and proper htmlFor attributes on their labels. This impacts accessibility as screen readers won't properly associate labels with their inputs.
For example:
<label htmlFor="minPrice" style={{...}}>
<MdAttachMoney size={18} />
Rango de Precio
</label>
<input
id="minPrice"
type="number"
...
/>Apply similar changes to all filter inputs and the category select element.
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| justifyContent: 'space-between', | ||
| alignItems: 'center', | ||
| }} | ||
| > | ||
| <div> | ||
| <h1 style={{ fontSize: '1.875rem', fontWeight: 700, color: theme.colors.textPrimary }}>Inventario</h1> | ||
| <p style={{ color: theme.colors.textSecondary }}>Gestión de productos y stock</p> | ||
| <h1 | ||
| style={{ | ||
| fontSize: '1.875rem', | ||
| fontWeight: 700, | ||
| color: theme.colors.textPrimary, | ||
| }} | ||
| > | ||
| Inventario | ||
| </h1> | ||
| <p style={{ color: theme.colors.textSecondary }}> | ||
| Gestión de productos y stock | ||
| </p> | ||
| </div> | ||
| <div style={{ display: 'flex', gap: theme.spacing.md }}> |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The "Filtros" button should include an aria-expanded attribute to indicate whether the filter panel is currently visible or hidden. This helps screen reader users understand the toggle state.
Update the button:
<button
onClick={() => setShowFilters(!showFilters)}
aria-expanded={showFilters}
aria-controls="filters-panel"
style={{...}}
>
<MdFilterList size={18} />
Filtros
</button>Also add id="filters-panel" to the filters container div (line 268) for proper ARIA association.
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdAttachMoney size={18} /> | ||
| Rango de Precio | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| ${filters.minPrice} - ${filters.maxPrice} | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="$0" | ||
| step="1" | ||
| value={filters.minPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minPrice: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="$10000" | ||
| step="1" | ||
| value={filters.maxPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxPrice: | ||
| e.target.value === '' | ||
| ? 10000 | ||
| : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Rango de Cantidad */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdInventory2 size={18} /> | ||
| Rango de Cantidad | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| {filters.minStock} - {filters.maxStock} unidades | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="0" | ||
| step="1" | ||
| value={filters.minStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minStock: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="10000" | ||
| step="1" | ||
| value={filters.maxStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxStock: |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The input allows negative values for price through manual typing. A user can type -100 in the minPrice field even though step="1" is set. This could lead to unexpected filter behavior or UI inconsistencies.
Add a min="0" attribute to prevent negative values:
<input
type="number"
placeholder="$0"
step="1"
min="0"
value={filters.minPrice || ''}
...
/>Apply the same fix to all number inputs (maxPrice, minStock, maxStock) on lines 385, 460, and 498.
| } catch (err: any) { | ||
| console.error('[Inventario] Error al eliminar del backend:', err); | ||
| setDeleteError(`Error al eliminar el producto: ${err?.message || 'Error desconocido'}`); | ||
| setDeleteError( |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The handleApplyFilters function could provide better UX by collapsing the filter panel after filters are applied, allowing users to see the filtered results immediately without manually closing the panel.
Consider adding:
const handleApplyFilters = () => {
setAppliedFilters(filters);
setShowFilters(false); // Auto-collapse after applying
};| </div> | ||
| )} | ||
|
|
||
| {/* Mensaje informativo cuando backend no está disponible */} | ||
| {productos.length > 0 && | ||
| productos[0]?.id === 1 && | ||
| productos[0]?.nombre === 'Laptop HP' && ( | ||
| <div | ||
| style={{ | ||
| backgroundColor: '#3498db20', | ||
| border: '1px solid #3498db', | ||
| borderRadius: theme.borderRadius.md, | ||
| padding: '12px 16px', | ||
| marginTop: theme.spacing.md, | ||
| color: '#3498db', | ||
| fontSize: '14px', | ||
| }} | ||
| > | ||
| ℹ️ Mostrando datos de ejemplo (backend no disponible). Para ver | ||
| datos reales, inicia el backend en puerto 3000. | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Campo de búsqueda */} |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The search input is missing a label element or aria-label attribute. While the placeholder provides a visual hint, screen reader users need a proper label to understand the purpose of this input.
Add an accessible label:
<label htmlFor="searchInput" style={{ /* visually hidden if needed */ }}>
Buscar producto por nombre
</label>
<input
id="searchInput"
type="text"
...
/>Alternatively, use aria-label="Buscar producto por nombre" on the input element.
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdAttachMoney size={18} /> | ||
| Rango de Precio | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| ${filters.minPrice} - ${filters.maxPrice} | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="$0" | ||
| step="1" | ||
| value={filters.minPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minPrice: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="$10000" | ||
| step="1" | ||
| value={filters.maxPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxPrice: | ||
| e.target.value === '' | ||
| ? 10000 | ||
| : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Rango de Cantidad */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdInventory2 size={18} /> | ||
| Rango de Cantidad | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| {filters.minStock} - {filters.maxStock} unidades | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="0" | ||
| step="1" | ||
| value={filters.minStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minStock: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="10000" | ||
| step="1" | ||
| value={filters.maxStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxStock: |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inline styles for input elements are heavily duplicated across multiple inputs (min/max price, min/max stock). The onFocus and onBlur handlers with style manipulation are repeated identically 6 times.
Consider extracting this to a reusable styled component or a shared style object and event handlers:
const inputStyles = {
flex: 1,
minWidth: '80px',
padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)',
// ... rest of styles
};
const handleInputFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.style.borderColor = theme.colors.primary;
e.target.style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`;
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.style.borderColor = theme.colors.border;
e.target.style.boxShadow = 'none';
};| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="$0" | ||
| step="1" | ||
| value={filters.minPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minPrice: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="$10000" | ||
| step="1" | ||
| value={filters.maxPrice || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxPrice: | ||
| e.target.value === '' | ||
| ? 10000 | ||
| : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Rango de Cantidad */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdInventory2 size={18} /> | ||
| Rango de Cantidad | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} | ||
| > | ||
| {filters.minStock} - {filters.maxStock} unidades | ||
| </p> | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <input | ||
| type="number" | ||
| placeholder="0" | ||
| step="1" | ||
| value={filters.minStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| minStock: | ||
| e.target.value === '' ? 0 : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="10000" | ||
| step="1" | ||
| value={filters.maxStock || ''} | ||
| onChange={(e) => | ||
| setFilters({ | ||
| ...filters, | ||
| maxStock: | ||
| e.target.value === '' | ||
| ? 10000 | ||
| : Number(e.target.value), | ||
| }) | ||
| } | ||
| style={{ | ||
| flex: 1, | ||
| minWidth: '80px', | ||
| padding: 'clamp(8px, 2%, 12px) clamp(10px, 2%, 14px)', | ||
| borderRadius: theme.borderRadius.sm, | ||
| border: `2px solid ${theme.colors.border}`, | ||
| backgroundColor: theme.colors.darkBgSecondary, | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 500, | ||
| transition: 'all 0.2s ease', | ||
| outline: 'none', | ||
| boxSizing: 'border-box', | ||
| }} | ||
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = | ||
| theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Categoría */} | ||
| <div> | ||
| <label | ||
| style={{ | ||
| color: theme.colors.textPrimary, | ||
| fontSize: 'clamp(12px, 4vw, 14px)', | ||
| fontWeight: 600, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '8px', | ||
| marginBottom: '12px', | ||
| }} | ||
| > | ||
| <MdCategory size={18} /> | ||
| Categoría | ||
| </label> | ||
| <p | ||
| style={{ | ||
| color: theme.colors.textSecondary, | ||
| fontSize: 'clamp(11px, 3vw, 12px)', | ||
| margin: '0 0 8px 0', | ||
| }} |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using (e.target as any) bypasses TypeScript's type safety. The event target is already typed as HTMLInputElement from the onChange handler, so you can safely access its style properties without type assertion.
Consider using a more type-safe approach:
onFocus={(e) => {
e.target.style.borderColor = theme.colors.primary;
e.target.style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`;
}}This pattern is repeated throughout the filter inputs and should be updated consistently.
| onFocus={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.primary; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 0 0 3px ${theme.colors.primary}20`; | ||
| }} | ||
| onBlur={(e) => { | ||
| (e.target as any).style.borderColor = theme.colors.border; | ||
| (e.target as any).style.boxShadow = 'none'; | ||
| }} | ||
| > | ||
| <option value="">Todas las categorías</option> | ||
| {categories.map((cat) => ( | ||
| <option key={cat.id} value={cat.nombre}> | ||
| {cat.nombre} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Botones de acción */} | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| gap: 'clamp(8px, 2%, 12px)', | ||
| paddingTop: '20px', | ||
| borderTop: `1px solid ${theme.colors.border}`, | ||
| flexWrap: 'wrap', | ||
| }} | ||
| > | ||
| <button | ||
| onClick={handleApplyFilters} | ||
| style={{ | ||
| padding: 'clamp(8px, 2%, 12px) clamp(16px, 4%, 28px)', | ||
| background: theme.colors.primary, | ||
| color: theme.colors.textOnPrimary, | ||
| border: 'none', | ||
| borderRadius: theme.borderRadius.md, | ||
| cursor: 'pointer', | ||
| fontSize: 'clamp(12px, 3vw, 14px)', | ||
| fontWeight: 700, | ||
| transition: 'all 0.2s ease', | ||
| boxShadow: `0 4px 12px ${theme.colors.primary}40`, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '6px', | ||
| }} | ||
| onMouseEnter={(e) => { | ||
| (e.target as any).style.transform = 'translateY(-2px)'; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 6px 16px ${theme.colors.primary}60`; | ||
| }} | ||
| onMouseLeave={(e) => { | ||
| (e.target as any).style.transform = 'translateY(0)'; | ||
| ( | ||
| e.target as any | ||
| ).style.boxShadow = `0 4px 12px ${theme.colors.primary}40`; | ||
| }} |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The "Aplicar Filtros" and "Limpiar" buttons use onMouseEnter and onMouseLeave for visual feedback, but these events don't work for keyboard users. Consider using :hover and :focus pseudo-classes via CSS-in-JS or adding onFocus handlers as well to ensure keyboard users get the same visual feedback.
Alternatively, consider using a CSS-in-JS solution or adding focus handlers:
onFocus={(e) => {
e.target.style.transform = 'translateY(-2px)';
e.target.style.boxShadow = `0 6px 16px ${theme.colors.primary}60`;
}}| > | ||
| Stock | ||
| </th> | ||
| <th | ||
| style={{ | ||
| padding: '12px', | ||
| textAlign: 'left', | ||
| fontSize: '12px', | ||
| fontWeight: 700, | ||
| color: theme.colors.textSecondary, | ||
| textTransform: 'uppercase', | ||
| letterSpacing: '0.5px', | ||
| }} | ||
| > | ||
| Precio | ||
| </th> | ||
| <th | ||
| style={{ | ||
| padding: '12px', | ||
| textAlign: 'left', | ||
| fontSize: '12px', | ||
| fontWeight: 700, | ||
| color: theme.colors.textSecondary, | ||
| textTransform: 'uppercase', | ||
| letterSpacing: '0.5px', | ||
| }} | ||
| > | ||
| Acciones | ||
| </th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {productos.map((producto) => ( | ||
| <tr key={producto.id} style={{ borderBottom: `1px solid ${theme.colors.border}` }}> | ||
| {filteredProductos.map((producto) => ( | ||
| <tr | ||
| key={producto.id} | ||
| style={{ | ||
| borderBottom: `1px solid ${theme.colors.border}`, | ||
| }} | ||
| > | ||
| <td style={{ padding: '12px' }}> | ||
| <div> | ||
| <div style={{ fontSize: '14px', fontWeight: 600, color: theme.colors.textPrimary }}>{producto.nombre}</div> | ||
| <div style={{ fontSize: '13px', color: theme.colors.textSecondary }}>{producto.descripcion}</div> | ||
| <div | ||
| style={{ | ||
| fontSize: '14px', | ||
| fontWeight: 600, | ||
| color: theme.colors.textPrimary, | ||
| }} | ||
| > | ||
| {producto.nombre} | ||
| </div> | ||
| <div | ||
| style={{ | ||
| fontSize: '13px', | ||
| color: theme.colors.textSecondary, | ||
| }} | ||
| > | ||
| {producto.descripcion} | ||
| </div> | ||
| </div> | ||
| </td> | ||
| <td style={{ padding: '12px', fontSize: '14px', color: theme.colors.textPrimary }}>{producto.categoria}</td> | ||
| <td | ||
| style={{ | ||
| padding: '12px', | ||
| fontSize: '14px', | ||
| color: theme.colors.textPrimary, | ||
| }} | ||
| > | ||
| {producto.categoria} | ||
| </td> | ||
| <td style={{ padding: '12px' }}> | ||
| <div style={{ fontSize: '14px', color: theme.colors.textPrimary }}> | ||
| <div | ||
| style={{ | ||
| fontSize: '14px', | ||
| color: theme.colors.textPrimary, | ||
| }} | ||
| > | ||
| <div>Disponible: {producto.stockDisponible}</div> | ||
| <div style={{ color: theme.colors.textSecondary }}>Reservado: {producto.stockReservado}</div> | ||
| <div style={{ color: theme.colors.textSecondary }}> | ||
| Reservado: {producto.stockReservado} | ||
| </div> | ||
| </div> | ||
| </td> | ||
| <td style={{ padding: '12px', fontSize: '14px', color: theme.colors.textPrimary }}>${Number(producto.precio).toFixed(2)}</td> | ||
| <td | ||
| style={{ | ||
| padding: '12px', | ||
| fontSize: '14px', | ||
| color: theme.colors.textPrimary, | ||
| }} | ||
| > |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When filters are applied and no products match the criteria, the UI shows an empty table body with no feedback to the user. This creates a confusing experience where users might think the page is broken or loading.
Add a check to display a "No products found" message:
<tbody>
{filteredProductos.length === 0 ? (
<tr>
<td colSpan={5} style={{ padding: '48px', textAlign: 'center', color: theme.colors.textSecondary }}>
No se encontraron productos que coincidan con los filtros aplicados
</td>
</tr>
) : (
filteredProductos.map((producto) => (
// ... existing product rows
))
)}
</tbody>| import { | ||
| getProducts, | ||
| listCategories, | ||
| deleteProduct, |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import deleteProduct.
| deleteProduct, |
No description provided.