'use client' import { useState, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { filamentService, colorService } from '@/src/services/api'; import { Filament } from '@/src/types/filament'; import { trackEvent } from '@/src/components/MatomoAnalytics'; import { SaleManager } from '@/src/components/SaleManager'; import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor'; // Removed unused imports for Bambu Lab color categorization import '@/src/styles/select.css'; // Colors that only come as refills (no spools) const REFILL_ONLY_COLORS = [ 'Beige', 'Light Gray', 'Yellow', 'Orange', 'Gold', 'Bright Green', 'Pink', 'Magenta', 'Maroon Red', 'Purple', 'Turquoise', 'Cobalt Blue', 'Brown', 'Bronze', 'Silver', 'Blue Grey', 'Dark Gray' ]; // Helper function to check if a filament is spool-only const isSpoolOnly = (finish?: string, type?: string): boolean => { return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || finish === 'Wood' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC'; }; // Helper function to check if a filament should be refill-only const isRefillOnly = (color: string, finish?: string, type?: string): boolean => { // If the finish/type combination is spool-only, then it's not refill-only if (isSpoolOnly(finish, type)) { return false; } // Translucent finish always has spool option if (finish === 'Translucent') { return false; } // Specific type/finish/color combinations that are refill-only if (type === 'ABS' && finish === 'GF' && (color === 'Yellow' || color === 'Orange')) { return true; } if (type === 'TPU' && finish === '95A HF') { return true; } // All colors starting with "Matte " prefix are refill-only if (color.startsWith('Matte ')) { return true; } // Galaxy and Basic colors have spools available (not refill-only) if (finish === 'Galaxy' || finish === 'Basic') { return false; } return REFILL_ONLY_COLORS.includes(color); }; // Helper function to filter colors based on material and finish const getFilteredColors = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => { // PPA CF only has black color if (type === 'PPA' && finish === 'CF') { return colors.filter(color => color.name.toLowerCase() === 'black'); } return colors; }; interface FilamentWithId extends Filament { id: string; createdAt?: string; updatedAt?: string; boja_hex?: string; } // Finish options by filament type const FINISH_OPTIONS_BY_TYPE: Record = { 'ABS': ['GF', 'Bez Finisha'], 'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Tough+', 'Translucent', 'Wood'], 'TPU': ['85A', '90A', '95A HF'], 'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'], 'PC': ['CF', 'FR', 'Bez Finisha'], 'ASA': ['Bez Finisha'], 'PA': ['CF', 'GF', 'Bez Finisha'], 'PA6': ['CF', 'GF'], 'PAHT': ['CF', 'Bez Finisha'], 'PPA': ['CF'], 'PVA': ['Bez Finisha'], 'HIPS': ['Bez Finisha'] }; export default function AdminDashboard() { const router = useRouter(); const [filaments, setFilaments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [editingFilament, setEditingFilament] = useState(null); const [showAddForm, setShowAddForm] = useState(false); const [darkMode, setDarkMode] = useState(false); const [mounted, setMounted] = useState(false); const [sortField, setSortField] = useState('boja'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [selectedFilaments, setSelectedFilaments] = useState>(new Set()); const [searchTerm, setSearchTerm] = useState(''); const [availableColors, setAvailableColors] = useState>([]); // Initialize dark mode - default to true for admin useEffect(() => { setMounted(true); const saved = localStorage.getItem('darkMode'); if (saved !== null) { setDarkMode(JSON.parse(saved)); } else { // Default to dark mode for admin setDarkMode(true); } }, []); useEffect(() => { if (!mounted) return; localStorage.setItem('darkMode', JSON.stringify(darkMode)); if (darkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [darkMode, mounted]); // Check authentication useEffect(() => { // Wait for component to mount to avoid SSR issues with localStorage if (!mounted) return; const token = localStorage.getItem('authToken'); const expiry = localStorage.getItem('tokenExpiry'); if (!token || !expiry || Date.now() > parseInt(expiry)) { window.location.href = '/upadaj'; } }, [mounted]); // Fetch filaments const fetchFilaments = async () => { try { setLoading(true); const filaments = await filamentService.getAll(); setFilaments(filaments); } catch (err) { setError('Greška pri učitavanju filamenata'); console.error('Fetch error:', err); } finally { setLoading(false); } }; const fetchAllData = async () => { // Fetch both filaments and colors await fetchFilaments(); try { const colors = await colorService.getAll(); setAvailableColors(colors.sort((a: any, b: any) => a.name.localeCompare(b.name))); } catch (error) { console.error('Error loading colors:', error); } }; useEffect(() => { fetchAllData(); }, []); // Sorting logic const handleSort = (field: string) => { if (sortField === field) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortOrder('asc'); } }; // Filter and sort filaments const filteredAndSortedFilaments = useMemo(() => { // First, filter by search term let filtered = filaments; if (searchTerm) { const search = searchTerm.toLowerCase(); filtered = filaments.filter(f => f.tip?.toLowerCase().includes(search) || f.finish?.toLowerCase().includes(search) || f.boja?.toLowerCase().includes(search) || f.cena?.toLowerCase().includes(search) ); } // Then sort if needed if (!sortField) return filtered; return [...filtered].sort((a, b) => { let aVal = a[sortField as keyof FilamentWithId]; let bVal = b[sortField as keyof FilamentWithId]; // Handle null/undefined values if (aVal === null || aVal === undefined) aVal = ''; if (bVal === null || bVal === undefined) bVal = ''; // Handle date fields if (sortField === 'created_at' || sortField === 'updated_at') { const aDate = new Date(String(aVal)); const bDate = new Date(String(bVal)); return sortOrder === 'asc' ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime(); } // Handle numeric fields if (sortField === 'kolicina' || sortField === 'refill' || sortField === 'spulna') { const aNum = Number(aVal) || 0; const bNum = Number(bVal) || 0; return sortOrder === 'asc' ? aNum - bNum : bNum - aNum; } // String comparison for other fields 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; }); }, [filaments, sortField, sortOrder, searchTerm]); const handleSave = async (filament: Partial) => { try { // Extract only the fields the API expects const { id, ...dataForApi } = filament; // Ensure numeric fields are numbers const cleanData = { tip: dataForApi.tip || 'PLA', finish: dataForApi.finish || 'Basic', boja: dataForApi.boja || '', boja_hex: dataForApi.boja_hex || '#000000', refill: Number(dataForApi.refill) || 0, spulna: Number(dataForApi.spulna) || 0, cena: dataForApi.cena || '3499' }; // Validate required fields if (!cleanData.tip || !cleanData.finish || !cleanData.boja) { setError('Tip, Finish, and Boja are required fields'); return; } if (id) { await filamentService.update(id, cleanData); trackEvent('Admin', 'Update Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`); } else { await filamentService.create(cleanData); trackEvent('Admin', 'Create Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`); } setEditingFilament(null); setShowAddForm(false); fetchAllData(); } catch (err: any) { if (err.response?.status === 401 || err.response?.status === 403) { setError('Sesija je istekla. Molimo prijavite se ponovo.'); setTimeout(() => { router.push('/upadaj'); }, 2000); } else { // Extract error message properly let errorMessage = 'Greška pri čuvanju filamenata'; if (err.response?.data?.error) { errorMessage = err.response.data.error; } else if (err.response?.data?.message) { errorMessage = err.response.data.message; } else if (typeof err.response?.data === 'string') { errorMessage = err.response.data; } else if (err.message) { errorMessage = err.message; } setError(errorMessage); console.error('Save error:', err.response?.data || err.message); } } }; const handleDelete = async (id: string) => { if (!confirm('Da li ste sigurni da želite obrisati ovaj filament?')) { return; } try { await filamentService.delete(id); fetchAllData(); } catch (err) { setError('Greška pri brisanju filamenata'); console.error('Delete error:', err); } }; const handleBulkDelete = async () => { if (selectedFilaments.size === 0) { setError('Molimo izaberite filamente za brisanje'); return; } if (!confirm(`Da li ste sigurni da želite obrisati ${selectedFilaments.size} filamenata?`)) { return; } try { // Delete all selected filaments await Promise.all(Array.from(selectedFilaments).map(id => filamentService.delete(id))); setSelectedFilaments(new Set()); fetchAllData(); } catch (err) { setError('Greška pri brisanju filamenata'); console.error('Bulk delete error:', err); } }; const toggleFilamentSelection = (filamentId: string) => { const newSelection = new Set(selectedFilaments); if (newSelection.has(filamentId)) { newSelection.delete(filamentId); } else { newSelection.add(filamentId); } setSelectedFilaments(newSelection); }; const toggleSelectAll = () => { if (selectedFilaments.size === filteredAndSortedFilaments.length) { setSelectedFilaments(new Set()); } else { setSelectedFilaments(new Set(filteredAndSortedFilaments.map(f => f.id))); } }; const handleLogout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('tokenExpiry'); router.push('/upadaj'); }; if (loading) { return
Učitavanje...
; } return (
{/* Main Content - Full Screen */}
Filamenteka Admin
{!showAddForm && !editingFilament && ( )} {selectedFilaments.size > 0 && ( )} {mounted && ( )}
{error && (
{error}
)} {/* Search Bar and Sorting */}
{/* Search Input */}
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" />
{/* Sort Dropdown */}
{/* Add/Edit Form */} {(showAddForm || editingFilament) && (
{ setEditingFilament(null); setShowAddForm(false); }} />
)} {/* Filaments Table */}
{filteredAndSortedFilaments.map((filament) => ( ))}
0 && selectedFilaments.size === filteredAndSortedFilaments.length} onChange={toggleSelectAll} className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" /> handleSort('tip')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')} handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')} handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')} handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')} handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')} handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')} handleSort('cena')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"> Cena {sortField === 'cena' && (sortOrder === 'asc' ? '↑' : '↓')} Popust Akcije
toggleFilamentSelection(filament.id)} className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" /> {filament.tip} {filament.finish}
{filament.boja_hex && (
)} {filament.boja}
{filament.refill > 0 ? ( {filament.refill} ) : ( 0 )} {filament.spulna > 0 ? ( {filament.spulna} ) : ( 0 )} {filament.kolicina} {(() => { // 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; } return ( <> {hasRefill && ( {refillPrice.toLocaleString('sr-RS')} )} {hasRefill && hasSpool && /} {hasSpool && ( {spoolPrice.toLocaleString('sr-RS')} )} RSD ); })()} {filament.sale_active && filament.sale_percentage ? (
-{filament.sale_percentage}% {filament.sale_end_date && (
do {new Date(filament.sale_end_date).toLocaleDateString('sr-RS')}
)}
) : ( - )}
); } // Filament Form Component function FilamentForm({ filament, filaments, availableColors, onSave, onCancel }: { filament: Partial, filaments: FilamentWithId[], availableColors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, onSave: (filament: Partial) => void, onCancel: () => void }) { const [formData, setFormData] = useState({ tip: filament.tip || (filament.id ? '' : 'PLA'), // Default to PLA for new filaments finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments boja: filament.boja || '', boja_hex: filament.boja_hex || '', refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0), // Store as number spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0), // Store as number kolicina: filament.kolicina || 0, // Default to 0, stored as number cena: '', // Price is now determined by color selection cena_refill: 0, cena_spulna: 0, }); // Track if this is the initial load to prevent price override const [isInitialLoad, setIsInitialLoad] = useState(true); // Update form when filament prop changes useEffect(() => { // Extract prices from the cena field if it exists (format: "3499/3999" or just "3499") let refillPrice = 0; let spulnaPrice = 0; if (filament.cena) { const prices = filament.cena.split('/'); refillPrice = parseInt(prices[0]) || 0; spulnaPrice = prices.length > 1 ? parseInt(prices[1]) || 0 : parseInt(prices[0]) || 0; } // Get default prices from color const colorData = availableColors.find(c => c.name === filament.boja); if (!refillPrice && colorData?.cena_refill) refillPrice = colorData.cena_refill; if (!spulnaPrice && colorData?.cena_spulna) spulnaPrice = colorData.cena_spulna; setFormData({ tip: filament.tip || (filament.id ? '' : 'PLA'), // Default to PLA for new filaments finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments boja: filament.boja || '', boja_hex: filament.boja_hex || '', refill: filament.refill || 0, // Store as number spulna: filament.spulna || 0, // Store as number kolicina: filament.kolicina || 0, // Default to 0, stored as number cena: filament.cena || '', // Keep original cena for compatibility cena_refill: refillPrice || 3499, cena_spulna: spulnaPrice || 3999, }); // Reset initial load flag when filament changes setIsInitialLoad(true); }, [filament]); // Update prices when color selection changes (but not on initial load) useEffect(() => { // Skip price update on initial load to preserve existing filament prices if (isInitialLoad) { setIsInitialLoad(false); return; } if (formData.boja && availableColors.length > 0) { const colorData = availableColors.find(c => c.name === formData.boja); if (colorData) { setFormData(prev => ({ ...prev, cena_refill: colorData.cena_refill || prev.cena_refill, cena_spulna: colorData.cena_spulna || prev.cena_spulna, })); } } }, [formData.boja, availableColors.length]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; if (name === 'refill' || name === 'spulna' || name === 'cena_refill' || name === 'cena_spulna') { // Convert to number for numeric fields const numValue = parseInt(value) || 0; setFormData({ ...formData, [name]: numValue }); } else if (name === 'tip') { // If changing filament type, reset finish if it's not compatible const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || []; const resetFinish = !newTypeFinishes.includes(formData.finish); const spoolOnly = isSpoolOnly(formData.finish, value); // If changing to PPA with CF finish and current color is not black, reset color const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black'; setFormData({ ...formData, [name]: value, ...(resetFinish ? { finish: '' } : {}), ...(spoolOnly ? { refill: 0 } : {}), ...(needsColorReset ? { boja: '' } : {}) }); } else if (name === 'boja') { // If changing to a refill-only color, reset spulna to 0 const refillOnly = isRefillOnly(value, formData.finish, formData.tip); setFormData({ ...formData, [name]: value, ...(refillOnly ? { spulna: 0 } : {}) }); } else if (name === 'finish') { // If changing to Translucent finish, enable spool option and disable refill const refillOnly = isRefillOnly(formData.boja, value, formData.tip); const spoolOnly = isSpoolOnly(value, formData.tip); // If changing to PPA CF and current color is not black, reset color const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black'; setFormData({ ...formData, [name]: value, ...(refillOnly ? { spulna: 0 } : {}), ...(spoolOnly ? { refill: 0 } : {}), ...(needsColorReset ? { boja: '' } : {}) }); } else { setFormData({ ...formData, [name]: value }); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Calculate total quantity const totalQuantity = formData.refill + formData.spulna; // Prevent adding filaments with 0 quantity if (totalQuantity === 0) { alert('Količina mora biti veća od 0. Dodajte refill ili spulna.'); return; } // Use the prices from the form (which can be edited) const refillPrice = formData.cena_refill; const spoolPrice = formData.cena_spulna; // Determine the price string based on what's in stock let priceString = ''; if (formData.refill > 0 && formData.spulna > 0) { priceString = `${refillPrice}/${spoolPrice}`; } else if (formData.refill > 0) { priceString = String(refillPrice); } else if (formData.spulna > 0) { priceString = String(spoolPrice); } else { priceString = '3499/3999'; // Default when no stock } // Pass only the data we want to save const dataToSave = { id: filament.id, tip: formData.tip, finish: formData.finish, boja: formData.boja, boja_hex: formData.boja_hex, refill: formData.refill, spulna: formData.spulna, cena: priceString }; onSave(dataToSave); }; return (

{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}

{formData.boja_hex && ( {formData.boja_hex} )}
c.name === formData.boja)?.cena_refill || 3499} onChange={handleChange} min="0" step="1" placeholder="3499" disabled={isSpoolOnly(formData.finish, formData.tip)} className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${ isSpoolOnly(formData.finish, formData.tip) ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed' : 'bg-white dark:bg-gray-700' } text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`} />
c.name === formData.boja)?.cena_spulna || 3999} onChange={handleChange} min="0" step="1" placeholder="3999" disabled={isRefillOnly(formData.boja, formData.finish)} className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${ isRefillOnly(formData.boja, formData.finish) ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400' : 'bg-white dark:bg-gray-700 text-blue-500 dark:text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500' }`} />
{/* Quantity inputs for refill, vakuum, and otvoreno */}
{/* Total quantity display */}
); }