336 lines
15 KiB
TypeScript
336 lines
15 KiB
TypeScript
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 { EnhancedFilters } from './EnhancedFilters';
|
|
import '../styles/select.css';
|
|
|
|
interface FilamentTableV2Props {
|
|
filaments: (Filament | FilamentV2)[];
|
|
loading?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loading, error }) => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [sortField, setSortField] = useState<string>('color.name');
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
|
const [filters, setFilters] = useState({
|
|
brand: '',
|
|
material: '',
|
|
storageCondition: '',
|
|
isRefill: null as boolean | null,
|
|
color: ''
|
|
});
|
|
|
|
// Convert legacy filaments to V2 format for display
|
|
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?.toLowerCase().includes('vakuum') ? 'vacuum' :
|
|
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)}`,
|
|
brand: legacy.brand,
|
|
type: legacy.tip as any || 'PLA',
|
|
material,
|
|
color: { name: legacy.boja },
|
|
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;
|
|
});
|
|
}, [filaments]);
|
|
|
|
// Get unique values for filters
|
|
const uniqueValues = useMemo(() => ({
|
|
brands: [...new Set(normalizedFilaments.map(f => f.brand))].sort(),
|
|
materials: [...new Set(normalizedFilaments.map(f => f.material.base))].sort(),
|
|
colors: [...new Set(normalizedFilaments.map(f => f.color.name))].sort()
|
|
}), [normalizedFilaments]);
|
|
|
|
// 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.brand.toLowerCase().includes(searchLower) ||
|
|
filament.material.base.toLowerCase().includes(searchLower) ||
|
|
(filament.material.modifier?.toLowerCase().includes(searchLower)) ||
|
|
filament.color.name.toLowerCase().includes(searchLower) ||
|
|
false; // SKU removed
|
|
|
|
// Other filters
|
|
const matchesBrand = !filters.brand || filament.brand === filters.brand;
|
|
const matchesMaterial = !filters.material ||
|
|
filament.material.base === filters.material ||
|
|
`${filament.material.base}-${filament.material.modifier}` === filters.material;
|
|
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;
|
|
|
|
return matchesSearch && matchesBrand && matchesMaterial && matchesStorage && matchesRefill && 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;
|
|
});
|
|
|
|
return filtered;
|
|
}, [normalizedFilaments, searchTerm, filters, sortField, sortOrder]);
|
|
|
|
const handleSort = (field: string) => {
|
|
if (sortField === field) {
|
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder('asc');
|
|
}
|
|
};
|
|
|
|
// Inventory summary
|
|
const inventorySummary = useMemo(() => {
|
|
const summary = {
|
|
totalSpools: 0,
|
|
availableSpools: 0,
|
|
vacuumCount: 0,
|
|
openedCount: 0,
|
|
refillCount: 0
|
|
};
|
|
|
|
normalizedFilaments.forEach(f => {
|
|
summary.totalSpools += f.inventory.total;
|
|
summary.availableSpools += f.inventory.available;
|
|
summary.vacuumCount += f.inventory.locations.vacuum;
|
|
summary.openedCount += f.inventory.locations.opened;
|
|
|
|
if (f.condition.isRefill) {
|
|
summary.refillCount += f.inventory.total;
|
|
}
|
|
});
|
|
|
|
return summary;
|
|
}, [normalizedFilaments]);
|
|
|
|
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">
|
|
{/* Inventory Summary */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno filamenta</div>
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">{inventorySummary.totalSpools}</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Dostupno</div>
|
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{inventorySummary.availableSpools}</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Vakum</div>
|
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{inventorySummary.vacuumCount}</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Otvoreno</div>
|
|
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{inventorySummary.openedCount}</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Refill</div>
|
|
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">{inventorySummary.refillCount}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Pretraži po brendu, materijalu, boji..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(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}
|
|
/>
|
|
|
|
{/* Icon Legend */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
|
<h3 className="text-base font-medium text-gray-700 dark:text-gray-300 mb-3 text-center">Legenda stanja:</h3>
|
|
<div className="flex justify-center gap-8">
|
|
<div className="flex items-center gap-3">
|
|
<div className="transform scale-125">
|
|
<InventoryBadge type="vacuum" count={1} />
|
|
</div>
|
|
<span className="text-gray-600 dark:text-gray-400 text-[15px]">Vakuum pakovanje</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="transform scale-125">
|
|
<InventoryBadge type="opened" count={1} />
|
|
</div>
|
|
<span className="text-gray-600 dark:text-gray-400 text-[15px]">Otvoreno</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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('brand')} 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">
|
|
Brend
|
|
</th>
|
|
<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>
|
|
<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>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Stanje
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Težina
|
|
</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>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Status
|
|
</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 text-sm font-medium text-gray-900 dark:text-white">
|
|
{filament.brand}
|
|
</td>
|
|
<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">
|
|
<div className="flex gap-2">
|
|
{filament.inventory.locations.vacuum > 0 && (
|
|
<InventoryBadge type="vacuum" count={filament.inventory.locations.vacuum} />
|
|
)}
|
|
{filament.inventory.locations.opened > 0 && (
|
|
<InventoryBadge type="opened" count={filament.inventory.locations.opened} />
|
|
)}
|
|
{filament.inventory.locations.printer > 0 && (
|
|
<InventoryBadge type="printer" count={filament.inventory.locations.printer} />
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
{filament.weight.value}{filament.weight.unit}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
|
{(() => {
|
|
// PLA Basic pricing logic
|
|
if (filament.material.base === 'PLA' && !filament.material.modifier) {
|
|
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';
|
|
}
|
|
}
|
|
// 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>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
|
Prikazano {filteredAndSortedFilaments.length} dostupnih filamenata
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |