Remove refresh icon and fix Safari/WebKit runtime errors

- Removed manual refresh button from frontend (kept auto-refresh functionality)
- Fixed WebKit 'object cannot be found' error by replacing absolute positioning with flexbox
- Added lazy loading to images to prevent preload warnings
- Cleaned up unused imports and variables:
  - Removed unused useRef import
  - Removed unused colors state variable and colorService
  - Removed unused ColorSwatch import from FilamentTableV2
  - Removed unused getModifierIcon function from MaterialBadge
- Updated tests to match current implementation
- Improved layout stability for better cross-browser compatibility
- Removed temporary migration scripts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DaX
2025-06-30 22:37:30 +02:00
parent 58b3ff2dec
commit 12e91d4c3e
33 changed files with 1646 additions and 668 deletions

View File

@@ -1,148 +1,113 @@
import React, { useState, useMemo } from 'react';
import { FilamentV2, isFilamentV2 } from '../types/filament.v2';
import { Filament } from '../types/filament';
import { ColorSwatch } from './ColorSwatch';
import { InventoryBadge } from './InventoryBadge';
import { MaterialBadge } from './MaterialBadge';
import React, { useState, useMemo, useEffect } from 'react';
import { Filament } from '@/src/types/filament';
import { EnhancedFilters } from './EnhancedFilters';
import '../styles/select.css';
import { colorService } from '@/src/services/api';
interface FilamentTableV2Props {
filaments: (Filament | FilamentV2)[];
loading?: boolean;
error?: string;
filaments: Filament[];
}
export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loading, error }) => {
const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<string>('color.name');
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: '',
storageCondition: '',
isRefill: null as boolean | null
color: ''
});
// Convert legacy filaments to V2 format for display
// 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.map(f => {
if (isFilamentV2(f)) return f;
// Convert legacy format
const legacy = f as Filament;
const material = {
base: legacy.tip || 'PLA',
modifier: legacy.finish !== 'Basic' ? legacy.finish : undefined
};
const storageCondition = (legacy.vakum === 'Da' || legacy.vakum?.toLowerCase().includes('vakuum')) ? 'vacuum' :
(legacy.otvoreno === 'Da' || legacy.otvoreno?.toLowerCase().includes('otvorena')) ? 'opened' : 'sealed';
const totalQuantity = parseInt(legacy.kolicina) || 1;
const availableQuantity = totalQuantity > 0 ? totalQuantity : 0;
return {
id: legacy.id || `legacy-${Math.random().toString(36).substr(2, 9)}`,
type: legacy.tip as any || 'PLA',
material,
color: { name: legacy.boja, hex: legacy.bojaHex || legacy.boja_hex || '#000000' },
weight: { value: 1000, unit: 'g' as const },
diameter: 1.75,
inventory: {
total: totalQuantity,
available: availableQuantity,
inUse: 0,
locations: {
vacuum: storageCondition === 'vacuum' ? totalQuantity : 0,
opened: storageCondition === 'opened' ? totalQuantity : 0,
printer: 0
}
},
pricing: {
purchasePrice: legacy.cena ? parseFloat(legacy.cena) : undefined,
currency: 'RSD' as const
},
condition: {
isRefill: legacy.refill === 'Da',
storageCondition: storageCondition as any
},
tags: [],
createdAt: '',
updatedAt: ''
} as FilamentV2;
});
return filaments;
}, [filaments]);
// Get unique values for filters
const uniqueValues = useMemo(() => ({
materials: [...new Set(normalizedFilaments.map(f => f.material.base))].sort(),
finishes: [...new Set(normalizedFilaments.map(f => f.material.modifier).filter(Boolean))].sort() as string[],
colors: [...new Set(normalizedFilaments.map(f => f.color.name))].sort()
}), [normalizedFilaments]);
materials: [...new Set(normalizedFilaments.map(f => f.tip))].sort(),
finishes: [...new Set(normalizedFilaments.map(f => f.finish))].sort(),
colors: availableColors ? availableColors.map(c => c.name) : [...new Set(normalizedFilaments.map(f => f.boja))].sort()
}), [normalizedFilaments, availableColors]);
// Filter and sort filaments
const filteredAndSortedFilaments = useMemo(() => {
let filtered = normalizedFilaments.filter(filament => {
// Only show available filaments
if (filament.inventory.available === 0) return false;
// Search filter
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
filament.material.base.toLowerCase().includes(searchLower) ||
(filament.material.modifier?.toLowerCase().includes(searchLower)) ||
filament.color.name.toLowerCase().includes(searchLower) ||
false; // SKU removed
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.material.base === filters.material;
const matchesFinish = !filters.finish || filament.material.modifier === filters.finish;
const matchesStorage = !filters.storageCondition || filament.condition.storageCondition === filters.storageCondition;
const matchesRefill = filters.isRefill === null || filament.condition.isRefill === filters.isRefill;
const matchesColor = !filters.color || filament.color.name === filters.color;
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 && matchesStorage && matchesRefill && matchesColor;
return matchesSearch && matchesMaterial && matchesFinish && matchesColor;
});
// Sort
filtered.sort((a, b) => {
let aVal: any = a;
let bVal: any = b;
// Handle nested properties
const fields = sortField.split('.');
for (const field of fields) {
aVal = aVal?.[field];
bVal = bVal?.[field];
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
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(sortOrder === 'asc' ? 'desc' : 'asc');
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
if (loading) {
return <div className="text-center py-8">Učitavanje...</div>;
}
if (error) {
return <div className="text-center py-8 text-red-500">{error}</div>;
}
return (
<div className="space-y-6">
@@ -173,68 +138,128 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
<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('material.base')} className="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">
Materijal
<th onClick={() => handleSort('tip')} className="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('color.name')} className="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
<th onClick={() => handleSort('finish')} className="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">
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('pricing.purchasePrice')} className="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
<th onClick={() => handleSort('boja')} className="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('condition.isRefill')} className="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">
Status
<th onClick={() => handleSort('refill')} className="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">
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="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">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="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-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 => (
<tr key={filament.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<MaterialBadge base={filament.material.base} modifier={filament.material.modifier} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ColorSwatch name={filament.color.name} hex={filament.color.hex} size="sm" />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{(() => {
// PLA Basic and Matte pricing logic
if (filament.material.base === 'PLA' && (!filament.material.modifier || filament.material.modifier === 'Matte')) {
if (filament.condition.isRefill && filament.condition.storageCondition !== 'opened') {
return '3.499 RSD';
} else if (!filament.condition.isRefill && filament.condition.storageCondition === 'vacuum') {
return '3.999 RSD';
{filteredAndSortedFilaments.map(filament => {
return (
<tr key={filament.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.tip}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.finish}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{filament.boja_hex && (
<div
className="w-7 h-7 rounded border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span className="text-sm text-gray-900 dark:text-gray-100">{filament.boja}</span>
</div>
</td>
<td className="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-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-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.kolicina}
</td>
<td className="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;
}
}
// Show original price if available
return filament.pricing.purchasePrice ?
`${filament.pricing.purchasePrice.toLocaleString('sr-RS')} ${filament.pricing.currency}` :
'-';
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{filament.condition.isRefill && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Refill
</span>
)}
{filament.inventory.available === 1 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Poslednji
</span>
)}
</div>
</td>
</tr>
))}
return (
<>
{hasRefill && (
<span className="text-green-600 dark:text-green-400">
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span className="text-blue-500 dark:text-blue-400">
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-gray-600 dark:text-gray-400">RSD</span>
</>
);
})()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
Prikazano {filteredAndSortedFilaments.length} dostupnih filamenata
Prikazano {filteredAndSortedFilaments.length} filamenata
</div>
</div>
);
};
};
export { FilamentTableV2 };