All checks were successful
Deploy / deploy (push) Successful in 2m24s
- 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
301 lines
14 KiB
TypeScript
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 }; |