Major restructure: Remove Confluence, add V2 data structure, organize for dev/prod

- Import real data from PDF (35 Bambu Lab filaments)
- Remove all Confluence integration and dependencies
- Implement new V2 data structure with proper inventory tracking
- Add backwards compatibility for existing data
- Create enhanced UI components (ColorSwatch, InventoryBadge, MaterialBadge)
- Add advanced filtering with quick filters and multi-criteria search
- Organize codebase for dev/prod environments
- Update Lambda functions to support both V1/V2 formats
- Add inventory summary dashboard
- Clean up project structure and documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DaX
2025-06-20 01:12:50 +02:00
parent a2252fa923
commit 18110ab159
40 changed files with 2171 additions and 1094 deletions

View File

@@ -0,0 +1,300 @@
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';
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: parseInt(legacy.kolicina) || 1,
available: storageCondition === 'opened' ? 0 : 1,
inUse: 0,
locations: {
vacuum: storageCondition === 'vacuum' ? 1 : 0,
opened: storageCondition === 'opened' ? 1 : 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 => {
// 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) ||
(filament.sku?.toLowerCase().includes(searchLower));
// 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,
totalWeight: 0,
brandsCount: new Set<string>(),
lowStock: [] as FilamentV2[]
};
normalizedFilaments.forEach(f => {
summary.totalSpools += f.inventory.total;
summary.availableSpools += f.inventory.available;
summary.totalWeight += f.inventory.total * f.weight.value;
summary.brandsCount.add(f.brand);
if (f.inventory.available <= 1 && f.inventory.total > 0) {
summary.lowStock.push(f);
}
});
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-4 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 kalema</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">Ukupna težina</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{(inventorySummary.totalWeight / 1000).toFixed(1)}kg</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">Malo na stanju</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{inventorySummary.lowStock.length}</div>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<input
type="text"
placeholder="Pretraži po brendu, materijalu, boji ili SKU..."
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}
/>
{/* 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('sku')} 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">
SKU
</th>
<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">
Skladište
</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 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-mono text-gray-500 dark:text-gray-400">
{filament.sku || filament.id.substring(0, 8)}
</td>
<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">
<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">
Punjenje
</span>
)}
{filament.inventory.available === 0 && (
<span className="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">
Nema na stanju
</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} od {normalizedFilaments.length} filamenata
</div>
</div>
);
};