Files
Filamenteka/src/components/FilamentTableV2.tsx
DaX 65ae493d54
All checks were successful
Deploy / deploy (push) Successful in 2m24s
Align catalog with Bambu Lab product line, add conditional filters and admin sidebar
- Add master catalog (bambuLabCatalog.ts) as single source of truth for materials, finishes, colors, and refill/spool availability
- Fix incorrect finish-per-material mappings (remove PLA: 85A/90A/95A HF/FR/GF/HF, add ASA: Basic/CF/Aero, fix PETG/PC)
- Implement cascading filters on public site: material restricts finish, finish restricts color
- Add AdminSidebar component across all admin pages
- Redirect /upadaj to /dashboard when already authenticated
- Update color hex mappings and tests to match official Bambu Lab names
2026-03-05 01:04:06 +01:00

301 lines
14 KiB
TypeScript

import React, { useState, useMemo, useEffect } from 'react';
import { Filament } from '@/src/types/filament';
import { EnhancedFilters } from './EnhancedFilters';
import { colorService } from '@/src/services/api';
import { trackEvent } from './MatomoAnalytics';
interface FilamentTableV2Props {
filaments: Filament[];
}
const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<string>('boja');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [availableColors, setAvailableColors] = useState<Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}> | null>(null);
const [filters, setFilters] = useState({
material: '',
finish: '',
color: ''
});
// Fetch all available colors from API
useEffect(() => {
const fetchColors = async () => {
try {
const colors = await colorService.getAll();
setAvailableColors(colors);
} catch (error) {
console.error('Error fetching colors:', error);
}
};
fetchColors();
}, []);
// Use filaments directly since they're already in the correct format
const normalizedFilaments = useMemo(() => {
return filaments;
}, [filaments]);
// Get unique values for filters (only from filaments with inventory)
const uniqueValues = useMemo(() => {
const filamentsWithInventory = normalizedFilaments.filter(f => f.kolicina > 0);
return {
materials: [...new Set(filamentsWithInventory.map(f => f.tip))].sort(),
finishes: [...new Set(filamentsWithInventory.map(f => f.finish))].sort(),
colors: [...new Set(filamentsWithInventory.map(f => f.boja))].sort()
};
}, [normalizedFilaments]);
// Filter and sort filaments
const filteredAndSortedFilaments = useMemo(() => {
let filtered = normalizedFilaments.filter(filament => {
// First, filter out filaments with zero inventory
if (filament.kolicina === 0) {
return false;
}
// Search filter
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
filament.tip.toLowerCase().includes(searchLower) ||
filament.finish.toLowerCase().includes(searchLower) ||
filament.boja.toLowerCase().includes(searchLower) ||
filament.cena.toLowerCase().includes(searchLower);
// Other filters
const matchesMaterial = !filters.material || filament.tip === filters.material;
const matchesFinish = !filters.finish || filament.finish === filters.finish;
const matchesColor = !filters.color || filament.boja === filters.color;
return matchesSearch && matchesMaterial && matchesFinish && matchesColor;
});
// Sort
if (sortField) {
filtered.sort((a, b) => {
let aVal: any = a[sortField as keyof typeof a];
let bVal: any = b[sortField as keyof typeof b];
// Handle numeric values
if (sortField === 'kolicina' || sortField === 'cena') {
aVal = parseFloat(String(aVal)) || 0;
bVal = parseFloat(String(bVal)) || 0;
}
// Handle spulna (extract numbers)
else if (sortField === 'spulna') {
const aMatch = String(aVal).match(/^(\d+)/);
const bMatch = String(bVal).match(/^(\d+)/);
aVal = aMatch ? parseInt(aMatch[1]) : 0;
bVal = bMatch ? parseInt(bMatch[1]) : 0;
}
// String comparison for other fields
else {
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [normalizedFilaments, searchTerm, filters, sortField, sortOrder]);
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
trackEvent('Table', 'Sort', field);
};
return (
<div className="space-y-6">
{/* Search Bar */}
<div className="relative">
<input
type="text"
placeholder="Pretraži po materijalu, boji..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (e.target.value) {
trackEvent('Search', 'Filament Search', e.target.value);
}
}}
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Enhanced Filters */}
<EnhancedFilters
filters={filters}
onFilterChange={setFilters}
uniqueValues={uniqueValues}
inventoryFilaments={filaments}
/>
{/* Table */}
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th onClick={() => handleSort('tip')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('finish')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('boja')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('refill')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('cena')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Cena {sortField === 'cena' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredAndSortedFilaments.map(filament => {
return (
<tr key={filament.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.tip}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.finish}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1 sm:gap-2">
{filament.boja_hex && (
<div
className="w-5 h-5 sm:w-7 sm:h-7 rounded border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span className="text-xs sm:text-sm text-gray-900 dark:text-gray-100">{filament.boja}</span>
</div>
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.refill > 0 ? (
<span className="text-green-600 dark:text-green-400 font-bold">{filament.refill}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">0</span>
)}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.spulna > 0 ? (
<span className="text-blue-500 dark:text-blue-400 font-bold">{filament.spulna}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">0</span>
)}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.kolicina}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">
{(() => {
// First check if filament has custom prices stored
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
if (!hasRefill && !hasSpool) return '-';
// Parse prices from the cena field if available (format: "3499" or "3499/3999")
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
// Single price - use for whatever is in stock
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
// Two prices - refill/spool format
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
} else {
// Fallback to color defaults if no custom price
const colorData = availableColors?.find(c => c.name === filament.boja);
refillPrice = colorData?.cena_refill || 3499;
spoolPrice = colorData?.cena_spulna || 3999;
}
// Calculate sale prices if applicable
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive ? Math.round(refillPrice * (1 - filament.sale_percentage! / 100)) : refillPrice;
const saleSpoolPrice = saleActive ? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100)) : spoolPrice;
return (
<>
{hasRefill && (
<span className={saleActive ? "line-through text-gray-500 dark:text-gray-400" : "text-green-600 dark:text-green-400"}>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && saleActive && (
<span className="text-green-600 dark:text-green-400 font-bold ml-1">
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span className={saleActive ? "line-through text-gray-500 dark:text-gray-400" : "text-blue-500 dark:text-blue-400"}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
{hasSpool && saleActive && (
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-gray-600 dark:text-gray-400">RSD</span>
{saleActive && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
-{filament.sale_percentage}%
</span>
)}
</>
);
})()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
Prikazano {filteredAndSortedFilaments.length} filamenata
</div>
</div>
);
};
export { FilamentTableV2 };