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:
79
src/components/ColorSwatch.tsx
Normal file
79
src/components/ColorSwatch.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ColorSwatchProps {
|
||||
name: string;
|
||||
hex?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ColorSwatch: React.FC<ColorSwatchProps> = ({
|
||||
name,
|
||||
hex,
|
||||
size = 'md',
|
||||
showLabel = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-10 h-10'
|
||||
};
|
||||
|
||||
// Default color mappings if hex is not provided
|
||||
const defaultColors: Record<string, string> = {
|
||||
'Black': '#000000',
|
||||
'White': '#FFFFFF',
|
||||
'Gray': '#808080',
|
||||
'Red': '#FF0000',
|
||||
'Blue': '#0000FF',
|
||||
'Green': '#00FF00',
|
||||
'Yellow': '#FFFF00',
|
||||
'Transparent': 'rgba(255, 255, 255, 0.1)'
|
||||
};
|
||||
|
||||
const getColorFromName = (colorName: string): string => {
|
||||
// Check exact match first
|
||||
if (defaultColors[colorName]) return defaultColors[colorName];
|
||||
|
||||
// Check if color name contains a known color
|
||||
const lowerName = colorName.toLowerCase();
|
||||
for (const [key, value] of Object.entries(defaultColors)) {
|
||||
if (lowerName.includes(key.toLowerCase())) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a color from the name hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < colorName.length; i++) {
|
||||
hash = colorName.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = hash % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
};
|
||||
|
||||
const backgroundColor = hex || getColorFromName(name);
|
||||
const isLight = backgroundColor.startsWith('#') &&
|
||||
parseInt(backgroundColor.slice(1, 3), 16) > 200 &&
|
||||
parseInt(backgroundColor.slice(3, 5), 16) > 200 &&
|
||||
parseInt(backgroundColor.slice(5, 7), 16) > 200;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div
|
||||
className={`${sizeClasses[size]} rounded-full border-2 ${isLight ? 'border-gray-300' : 'border-gray-700'} shadow-sm`}
|
||||
style={{ backgroundColor }}
|
||||
title={name}
|
||||
>
|
||||
{name.toLowerCase().includes('transparent') && (
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-br from-gray-200 to-gray-300 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
204
src/components/EnhancedFilters.tsx
Normal file
204
src/components/EnhancedFilters.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
|
||||
interface EnhancedFiltersProps {
|
||||
filters: {
|
||||
brand: string;
|
||||
material: string;
|
||||
storageCondition: string;
|
||||
isRefill: boolean | null;
|
||||
color: string;
|
||||
};
|
||||
onFilterChange: (filters: any) => void;
|
||||
uniqueValues: {
|
||||
brands: string[];
|
||||
materials: string[];
|
||||
colors: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
filters,
|
||||
onFilterChange,
|
||||
uniqueValues
|
||||
}) => {
|
||||
const quickFilters = [
|
||||
{ id: 'ready', label: 'Spremno za upotrebu', icon: '✅' },
|
||||
{ id: 'lowStock', label: 'Malo na stanju', icon: '⚠️' },
|
||||
{ id: 'refills', label: 'Samo punjenja', icon: '♻️' },
|
||||
{ id: 'sealed', label: 'Zapakovano', icon: '📦' },
|
||||
{ id: 'opened', label: 'Otvoreno', icon: '📂' }
|
||||
];
|
||||
|
||||
const handleQuickFilter = (filterId: string) => {
|
||||
switch (filterId) {
|
||||
case 'ready':
|
||||
onFilterChange({ ...filters, storageCondition: 'vacuum' });
|
||||
break;
|
||||
case 'lowStock':
|
||||
// This would need backend support
|
||||
onFilterChange({ ...filters });
|
||||
break;
|
||||
case 'refills':
|
||||
onFilterChange({ ...filters, isRefill: true });
|
||||
break;
|
||||
case 'sealed':
|
||||
onFilterChange({ ...filters, storageCondition: 'vacuum' });
|
||||
break;
|
||||
case 'opened':
|
||||
onFilterChange({ ...filters, storageCondition: 'opened' });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Quick Filters */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Brzi filteri
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickFilters.map(filter => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => handleQuickFilter(filter.id)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm
|
||||
bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600
|
||||
hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span>{filter.icon}</span>
|
||||
<span>{filter.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{/* Brand Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Brend
|
||||
</label>
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={(e) => onFilterChange({ ...filters, brand: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Svi brendovi</option>
|
||||
{uniqueValues.brands.map(brand => (
|
||||
<option key={brand} value={brand}>{brand}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Material Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Materijal
|
||||
</label>
|
||||
<select
|
||||
value={filters.material}
|
||||
onChange={(e) => onFilterChange({ ...filters, material: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Svi materijali</option>
|
||||
<optgroup label="Osnovni">
|
||||
<option value="PLA">PLA</option>
|
||||
<option value="PETG">PETG</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="TPU">TPU</option>
|
||||
</optgroup>
|
||||
<optgroup label="Specijalni">
|
||||
<option value="PLA-Silk">PLA Silk</option>
|
||||
<option value="PLA-Matte">PLA Matte</option>
|
||||
<option value="PLA-CF">PLA Carbon Fiber</option>
|
||||
<option value="PLA-Wood">PLA Wood</option>
|
||||
<option value="PLA-Glow">PLA Glow</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Boja
|
||||
</label>
|
||||
<select
|
||||
value={filters.color}
|
||||
onChange={(e) => onFilterChange({ ...filters, color: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Sve boje</option>
|
||||
{uniqueValues.colors.map(color => (
|
||||
<option key={color} value={color}>{color}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Storage Condition */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Skladištenje
|
||||
</label>
|
||||
<select
|
||||
value={filters.storageCondition}
|
||||
onChange={(e) => onFilterChange({ ...filters, storageCondition: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Sve lokacije</option>
|
||||
<option value="vacuum">Vakuum</option>
|
||||
<option value="sealed">Zapakovano</option>
|
||||
<option value="opened">Otvoreno</option>
|
||||
<option value="desiccant">Sa sušačem</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Refill Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tip
|
||||
</label>
|
||||
<select
|
||||
value={filters.isRefill === null ? '' : filters.isRefill ? 'refill' : 'original'}
|
||||
onChange={(e) => onFilterChange({
|
||||
...filters,
|
||||
isRefill: e.target.value === '' ? null : e.target.value === 'refill'
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Svi tipovi</option>
|
||||
<option value="original">Originalno pakovanje</option>
|
||||
<option value="refill">Punjenje</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onFilterChange({
|
||||
brand: '',
|
||||
material: '',
|
||||
storageCondition: '',
|
||||
isRefill: null,
|
||||
color: ''
|
||||
})}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Obriši sve filtere
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -58,8 +58,8 @@ export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const aValue = a[sortField];
|
||||
const bValue = b[sortField];
|
||||
const aValue = a[sortField] || '';
|
||||
const bValue = b[sortField] || '';
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue.localeCompare(bValue);
|
||||
|
||||
300
src/components/FilamentTableV2.tsx
Normal file
300
src/components/FilamentTableV2.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
src/components/InventoryBadge.tsx
Normal file
89
src/components/InventoryBadge.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InventoryBadgeProps {
|
||||
type: 'vacuum' | 'opened' | 'printer' | 'total' | 'available';
|
||||
count: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InventoryBadge: React.FC<InventoryBadgeProps> = ({ type, count, className = '' }) => {
|
||||
if (count === 0) return null;
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'vacuum':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
);
|
||||
case 'opened':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
);
|
||||
case 'printer':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
);
|
||||
case 'total':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
);
|
||||
case 'available':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = () => {
|
||||
switch (type) {
|
||||
case 'vacuum':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'opened':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
case 'printer':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||
case 'total':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
case 'available':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
switch (type) {
|
||||
case 'vacuum':
|
||||
return 'Vakuum';
|
||||
case 'opened':
|
||||
return 'Otvoreno';
|
||||
case 'printer':
|
||||
return 'U printeru';
|
||||
case 'total':
|
||||
return 'Ukupno';
|
||||
case 'available':
|
||||
return 'Dostupno';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getColor()} ${className}`}>
|
||||
{getIcon()}
|
||||
<span>{count}</span>
|
||||
<span className="sr-only">{getLabel()}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
54
src/components/MaterialBadge.tsx
Normal file
54
src/components/MaterialBadge.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MaterialBadgeProps {
|
||||
base: string;
|
||||
modifier?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MaterialBadge: React.FC<MaterialBadgeProps> = ({ base, modifier, className = '' }) => {
|
||||
const getBaseColor = () => {
|
||||
switch (base) {
|
||||
case 'PLA':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'PETG':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'ABS':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
case 'TPU':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getModifierIcon = () => {
|
||||
switch (modifier) {
|
||||
case 'Silk':
|
||||
return '✨';
|
||||
case 'Matte':
|
||||
return '🔵';
|
||||
case 'Glow':
|
||||
return '💡';
|
||||
case 'Wood':
|
||||
return '🪵';
|
||||
case 'CF':
|
||||
return '⚫';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getBaseColor()}`}>
|
||||
{base}
|
||||
</span>
|
||||
{modifier && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{getModifierIcon()} {modifier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,176 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export interface Filament {
|
||||
brand: string;
|
||||
tip: string;
|
||||
finish: string;
|
||||
boja: string;
|
||||
refill: string;
|
||||
vakum: string;
|
||||
otvoreno: string;
|
||||
kolicina: string;
|
||||
cena: string;
|
||||
}
|
||||
|
||||
const mockFilaments: Filament[] = [
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Mistletoe Green", refill: "", vakum: "vakuum x1", otvoreno: "otvorena x1", kolicina: "2", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Indigo Purple", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Black", refill: "", vakum: "", otvoreno: "2x otvorena", kolicina: "2", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Black", refill: "Da", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Jade White", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Gray", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Red", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Hot Pink", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Cocoa Brown", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "White", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Cotton Candy Cloud", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Sunflower Yellow", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Yellow", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Magenta", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Beige", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Cyan", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Scarlet Red", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Mandarin Orange", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Marine Blue", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Charcoal", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
|
||||
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Ivory White", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" }
|
||||
];
|
||||
|
||||
export async function fetchFromConfluence(env: any): Promise<Filament[]> {
|
||||
const confluenceUrl = env.CONFLUENCE_API_URL;
|
||||
const confluenceToken = env.CONFLUENCE_TOKEN;
|
||||
const pageId = env.CONFLUENCE_PAGE_ID;
|
||||
|
||||
console.log('Confluence config:', {
|
||||
url: confluenceUrl ? 'Set' : 'Missing',
|
||||
token: confluenceToken ? 'Set' : 'Missing',
|
||||
pageId: pageId || 'Missing'
|
||||
});
|
||||
|
||||
if (!confluenceUrl || !confluenceToken || !pageId) {
|
||||
console.warn('Confluence configuration missing, using mock data');
|
||||
return mockFilaments;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Fetching from Confluence:', `${confluenceUrl}/wiki/rest/api/content/${pageId}`);
|
||||
|
||||
// Create Basic auth token from email and API token
|
||||
const auth = Buffer.from(`dax@demirix.com:${confluenceToken}`).toString('base64');
|
||||
|
||||
const response = await axios.get(
|
||||
`${confluenceUrl}/wiki/rest/api/content/${pageId}?expand=body.storage`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
const htmlContent = response.data.body?.storage?.value || '';
|
||||
|
||||
if (!htmlContent) {
|
||||
console.error('No HTML content in response');
|
||||
throw new Error('No content found');
|
||||
}
|
||||
|
||||
const filaments = parseConfluenceTable(htmlContent);
|
||||
|
||||
// Always return parsed data, never fall back to mock
|
||||
console.log(`Returning ${filaments.length} filaments from Confluence`);
|
||||
return filaments;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch from Confluence:', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error('Response:', error.response?.status, error.response?.data);
|
||||
}
|
||||
throw error; // Don't return mock data
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfluenceTable(html: string): Filament[] {
|
||||
const $ = cheerio.load(html);
|
||||
const filaments: Filament[] = [];
|
||||
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('Number of tables found:', $('table').length);
|
||||
|
||||
// Find all tables and process each one
|
||||
$('table').each((tableIndex, table) => {
|
||||
let headers: string[] = [];
|
||||
|
||||
// Get headers
|
||||
$(table).find('tr').first().find('th, td').each((_i, cell) => {
|
||||
headers.push($(cell).text().trim());
|
||||
});
|
||||
|
||||
console.log(`Table ${tableIndex} headers:`, headers);
|
||||
|
||||
// Skip if not our filament table (check for expected headers)
|
||||
if (!headers.includes('Boja') || !headers.includes('Brand')) {
|
||||
console.log(`Skipping table ${tableIndex} - missing required headers`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process rows
|
||||
$(table).find('tr').slice(1).each((_rowIndex, row) => {
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length >= headers.length) {
|
||||
const filament: any = {};
|
||||
|
||||
cells.each((cellIndex, cell) => {
|
||||
const headerName = headers[cellIndex];
|
||||
const cellText = $(cell).text().trim();
|
||||
|
||||
// Debug log for the problematic column
|
||||
if (cellIndex === 7) { // Količina should be the 8th column (index 7)
|
||||
console.log(`Column 7 - Header: "${headerName}", Value: "${cellText}"`);
|
||||
}
|
||||
|
||||
// Map headers to our expected structure
|
||||
switch(headerName.toLowerCase()) {
|
||||
case 'brand':
|
||||
filament.brand = cellText;
|
||||
break;
|
||||
case 'tip':
|
||||
filament.tip = cellText;
|
||||
break;
|
||||
case 'finish':
|
||||
filament.finish = cellText;
|
||||
break;
|
||||
case 'boja':
|
||||
filament.boja = cellText;
|
||||
break;
|
||||
case 'refill':
|
||||
filament.refill = cellText;
|
||||
break;
|
||||
case 'vakum':
|
||||
filament.vakum = cellText;
|
||||
break;
|
||||
case 'otvoreno':
|
||||
filament.otvoreno = cellText;
|
||||
break;
|
||||
case 'količina':
|
||||
case 'kolicina': // Handle possible typo
|
||||
filament.kolicina = cellText;
|
||||
break;
|
||||
case 'cena':
|
||||
filament.cena = cellText;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Only add if we have the required fields
|
||||
if (filament.brand && filament.boja) {
|
||||
filaments.push(filament as Filament);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`Parsed ${filaments.length} filaments from Confluence`);
|
||||
return filaments; // Return whatever we found, don't fall back to mock
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface Filament {
|
||||
id?: string;
|
||||
brand: string;
|
||||
tip: string;
|
||||
finish: string;
|
||||
@@ -8,4 +9,7 @@ export interface Filament {
|
||||
otvoreno: string;
|
||||
kolicina: string;
|
||||
cena: string;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
142
src/types/filament.v2.ts
Normal file
142
src/types/filament.v2.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// Version 2 - Improved filament data structure
|
||||
|
||||
export type MaterialBase = 'PLA' | 'PETG' | 'ABS' | 'TPU' | 'SILK' | 'CF' | 'WOOD';
|
||||
export type MaterialModifier = 'Silk' | 'Matte' | 'Glow' | 'Wood' | 'CF';
|
||||
export type StorageCondition = 'vacuum' | 'sealed' | 'opened' | 'desiccant';
|
||||
export type Currency = 'RSD' | 'EUR' | 'USD';
|
||||
|
||||
export interface Material {
|
||||
base: MaterialBase;
|
||||
modifier?: MaterialModifier;
|
||||
}
|
||||
|
||||
export interface Color {
|
||||
name: string;
|
||||
hex?: string;
|
||||
pantone?: string;
|
||||
}
|
||||
|
||||
export interface Weight {
|
||||
value: number;
|
||||
unit: 'g' | 'kg';
|
||||
}
|
||||
|
||||
export interface InventoryLocation {
|
||||
vacuum: number;
|
||||
opened: number;
|
||||
printer: number;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
total: number;
|
||||
available: number;
|
||||
inUse: number;
|
||||
locations: InventoryLocation;
|
||||
}
|
||||
|
||||
export interface Pricing {
|
||||
purchasePrice?: number;
|
||||
currency: Currency;
|
||||
supplier?: string;
|
||||
purchaseDate?: string;
|
||||
}
|
||||
|
||||
export interface Condition {
|
||||
isRefill: boolean;
|
||||
openedDate?: string;
|
||||
expiryDate?: string;
|
||||
storageCondition: StorageCondition;
|
||||
humidity?: number;
|
||||
}
|
||||
|
||||
// Legacy fields for backwards compatibility
|
||||
export interface LegacyFields {
|
||||
tip: string;
|
||||
finish: string;
|
||||
boja: string;
|
||||
refill: string;
|
||||
vakum: string;
|
||||
otvoreno: string;
|
||||
kolicina: string;
|
||||
cena: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface FilamentV2 {
|
||||
// Identifiers
|
||||
id: string;
|
||||
sku?: string;
|
||||
|
||||
// Product Info
|
||||
brand: string;
|
||||
type: MaterialBase;
|
||||
material: Material;
|
||||
color: Color;
|
||||
|
||||
// Physical Properties
|
||||
weight: Weight;
|
||||
diameter: number;
|
||||
|
||||
// Inventory Status
|
||||
inventory: Inventory;
|
||||
|
||||
// Purchase Info
|
||||
pricing: Pricing;
|
||||
|
||||
// Condition
|
||||
condition: Condition;
|
||||
|
||||
// Metadata
|
||||
tags: string[];
|
||||
notes?: string;
|
||||
images?: string[];
|
||||
|
||||
// Timestamps
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsed?: string;
|
||||
|
||||
// Backwards compatibility
|
||||
_legacy?: LegacyFields;
|
||||
}
|
||||
|
||||
// Helper type guards
|
||||
export const isFilamentV2 = (filament: any): filament is FilamentV2 => {
|
||||
return filament &&
|
||||
typeof filament === 'object' &&
|
||||
'material' in filament &&
|
||||
'inventory' in filament &&
|
||||
'condition' in filament;
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const getTotalWeight = (filament: FilamentV2): number => {
|
||||
const multiplier = filament.weight.unit === 'kg' ? 1000 : 1;
|
||||
return filament.inventory.total * filament.weight.value * multiplier;
|
||||
};
|
||||
|
||||
export const getAvailableWeight = (filament: FilamentV2): number => {
|
||||
const multiplier = filament.weight.unit === 'kg' ? 1000 : 1;
|
||||
return filament.inventory.available * filament.weight.value * multiplier;
|
||||
};
|
||||
|
||||
export const isLowStock = (filament: FilamentV2, threshold = 1): boolean => {
|
||||
return filament.inventory.available <= threshold && filament.inventory.total > 0;
|
||||
};
|
||||
|
||||
export const needsRefill = (filament: FilamentV2): boolean => {
|
||||
return filament.inventory.available === 0 && filament.inventory.total > 0;
|
||||
};
|
||||
|
||||
export const isExpired = (filament: FilamentV2): boolean => {
|
||||
if (!filament.condition.expiryDate) return false;
|
||||
return new Date(filament.condition.expiryDate) < new Date();
|
||||
};
|
||||
|
||||
export const daysOpen = (filament: FilamentV2): number | null => {
|
||||
if (!filament.condition.openedDate) return null;
|
||||
const opened = new Date(filament.condition.openedDate);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - opened.getTime();
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
Reference in New Issue
Block a user