Refactor to multi-category catalog with polished light mode

- Restructure from single filament table to multi-category product catalog
  (filamenti, stampaci, ploce, mlaznice, delovi, oprema)
- Add shared layout components (SiteHeader, SiteFooter, CategoryNav, Breadcrumb)
- Add reusable UI primitives (Badge, Button, Card, Modal, PriceDisplay, EmptyState)
- Add catalog components (CatalogPage, ProductTable, ProductGrid, FilamentCard, ProductCard)
- Add admin dashboard with sidebar navigation and category management
- Add product API endpoints and database migrations
- Add SEO pages (politika-privatnosti, uslovi-koriscenja, robots.txt, sitemap.xml)
- Fix light mode: gradient text contrast, category nav accessibility,
  surface tokens, card shadows, CTA section theming
This commit is contained in:
DaX
2026-02-21 21:56:17 +01:00
parent a854fd5524
commit 1d3d11afec
62 changed files with 8618 additions and 358 deletions

View File

@@ -0,0 +1,13 @@
export function generateStaticParams() {
return [
{ category: 'stampaci' },
{ category: 'ploce' },
{ category: 'mlaznice' },
{ category: 'delovi' },
{ category: 'oprema' },
];
}
export default function CategoryLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,577 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useParams, notFound } from 'next/navigation';
import { productService, printerModelService } from '@/src/services/api';
import { Product, ProductCategory, ProductCondition, PrinterModel } from '@/src/types/product';
import '@/src/styles/select.css';
const CATEGORY_MAP: Record<string, { category: ProductCategory; label: string; plural: string }> = {
stampaci: { category: 'printer', label: 'Stampac', plural: 'Stampaci' },
ploce: { category: 'build_plate', label: 'Ploca', plural: 'Ploce' },
mlaznice: { category: 'nozzle', label: 'Mlaznica', plural: 'Mlaznice' },
delovi: { category: 'spare_part', label: 'Deo', plural: 'Delovi' },
oprema: { category: 'accessory', label: 'Oprema', plural: 'Oprema' },
};
const CONDITION_LABELS: Record<string, string> = {
new: 'Novo',
used_like_new: 'Korisceno - kao novo',
used_good: 'Korisceno - dobro',
used_fair: 'Korisceno - pristojno',
};
// Categories that support printer compatibility
const PRINTER_COMPAT_CATEGORIES: ProductCategory[] = ['build_plate', 'nozzle', 'spare_part'];
export default function CategoryPage() {
const params = useParams();
const slug = params.category as string;
const categoryConfig = CATEGORY_MAP[slug];
// If slug is not recognized, show not found
if (!categoryConfig) {
notFound();
}
const { category, label, plural } = categoryConfig;
const [products, setProducts] = useState<Product[]>([]);
const [printerModels, setPrinterModels] = useState<PrinterModel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<string>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [selectedProducts, setSelectedProducts] = useState<Set<string>>(new Set());
const fetchProducts = async () => {
try {
setLoading(true);
const [data, models] = await Promise.all([
productService.getAll({ category }),
PRINTER_COMPAT_CATEGORIES.includes(category)
? printerModelService.getAll().catch(() => [])
: Promise.resolve([]),
]);
setProducts(data);
setPrinterModels(models);
} catch (err) {
setError('Greska pri ucitavanju proizvoda');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, [category]);
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
const filteredAndSorted = useMemo(() => {
let filtered = products;
if (searchTerm) {
const search = searchTerm.toLowerCase();
filtered = products.filter(p =>
p.name.toLowerCase().includes(search) ||
p.description?.toLowerCase().includes(search)
);
}
return [...filtered].sort((a, b) => {
let aVal: any = a[sortField as keyof Product] || '';
let bVal: any = b[sortField as keyof Product] || '';
if (sortField === 'price' || sortField === 'stock') {
aVal = Number(aVal) || 0;
bVal = Number(bVal) || 0;
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
}
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;
});
}, [products, searchTerm, sortField, sortOrder]);
const handleSave = async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
try {
const dataToSave = {
...product,
category,
};
if (product.id) {
await productService.update(product.id, dataToSave);
} else {
await productService.create(dataToSave);
}
setEditingProduct(null);
setShowAddForm(false);
fetchProducts();
} catch (err: any) {
const msg = err.response?.data?.error || err.message || 'Greska pri cuvanju proizvoda';
setError(msg);
console.error('Save error:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovaj proizvod?')) return;
try {
await productService.delete(id);
fetchProducts();
} catch (err) {
setError('Greska pri brisanju proizvoda');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedProducts.size === 0) return;
if (!confirm(`Obrisati ${selectedProducts.size} proizvoda?`)) return;
try {
await Promise.all(Array.from(selectedProducts).map(id => productService.delete(id)));
setSelectedProducts(new Set());
fetchProducts();
} catch (err) {
setError('Greska pri brisanju proizvoda');
console.error('Bulk delete error:', err);
}
};
const toggleSelection = (id: string) => {
const next = new Set(selectedProducts);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedProducts(next);
};
const toggleSelectAll = () => {
if (selectedProducts.size === filteredAndSorted.length) {
setSelectedProducts(new Set());
} else {
setSelectedProducts(new Set(filteredAndSorted.map(p => p.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">{plural}</h1>
<p className="text-white/40 mt-1">{products.length} proizvoda ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingProduct && (
<button
onClick={() => {
setShowAddForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm"
>
Dodaj {label.toLowerCase()}
</button>
)}
{selectedProducts.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
>
Obrisi izabrane ({selectedProducts.size})
</button>
)}
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi proizvode..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" 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>
{/* Add/Edit Form */}
{(showAddForm || editingProduct) && (
<ProductForm
product={editingProduct || undefined}
printerModels={printerModels}
showPrinterCompat={PRINTER_COMPAT_CATEGORIES.includes(category)}
onSave={handleSave}
onCancel={() => {
setEditingProduct(null);
setShowAddForm(false);
}}
/>
)}
{/* Products Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left">
<input
type="checkbox"
checked={filteredAndSorted.length > 0 && selectedProducts.size === filteredAndSorted.length}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
/>
</th>
<th onClick={() => handleSort('name')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Naziv {sortField === 'name' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('condition')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Stanje {sortField === 'condition' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('price')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Cena {sortField === 'price' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('stock')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Kolicina {sortField === 'stock' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Popust</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Akcije</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{filteredAndSorted.map(product => (
<tr key={product.id} className="hover:bg-white/[0.06]">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedProducts.has(product.id)}
onChange={() => toggleSelection(product.id)}
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{product.image_url && (
<img src={product.image_url} alt={product.name} className="w-10 h-10 rounded object-cover" />
)}
<div>
<div className="text-sm font-medium text-white/90">{product.name}</div>
{product.description && (
<div className="text-xs text-white/40 truncate max-w-xs">{product.description}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-white/60">
{CONDITION_LABELS[product.condition] || product.condition}
</td>
<td className="px-4 py-3 text-sm font-bold text-white/90">
{product.price.toLocaleString('sr-RS')} RSD
</td>
<td className="px-4 py-3 text-sm">
{product.stock > 2 ? (
<span className="text-green-400 font-bold">{product.stock}</span>
) : product.stock > 0 ? (
<span className="text-yellow-400 font-bold">{product.stock}</span>
) : (
<span className="text-red-400 font-bold">0</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{product.sale_active && product.sale_percentage ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
-{product.sale_percentage}%
</span>
) : (
<span className="text-white/30">-</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => {
setEditingProduct(product);
setShowAddForm(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(product.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
{filteredAndSorted.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-white/40">
Nema proizvoda u ovoj kategoriji
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// Product Form Component
function ProductForm({
product,
printerModels,
showPrinterCompat,
onSave,
onCancel,
}: {
product?: Product;
printerModels: PrinterModel[];
showPrinterCompat: boolean;
onSave: (data: Partial<Product> & { printer_model_ids?: string[] }) => void;
onCancel: () => void;
}) {
const [formData, setFormData] = useState({
name: product?.name || '',
description: product?.description || '',
price: product?.price || 0,
condition: (product?.condition || 'new') as ProductCondition,
stock: product?.stock || 0,
image_url: product?.image_url || '',
attributes: JSON.stringify(product?.attributes || {}, null, 2),
});
const [selectedPrinterIds, setSelectedPrinterIds] = useState<string[]>([]);
// Load compatible printers on mount
useEffect(() => {
if (product?.compatible_printers) {
// Map names to IDs
const ids = printerModels
.filter(m => product.compatible_printers?.includes(m.name))
.map(m => m.id);
setSelectedPrinterIds(ids);
}
}, [product, printerModels]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? (parseFloat(value) || 0) : value,
}));
};
const togglePrinter = (id: string) => {
setSelectedPrinterIds(prev =>
prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
alert('Naziv je obavezan');
return;
}
let attrs = {};
try {
attrs = formData.attributes.trim() ? JSON.parse(formData.attributes) : {};
} catch {
alert('Atributi moraju biti validan JSON');
return;
}
onSave({
id: product?.id,
name: formData.name,
description: formData.description || undefined,
price: formData.price,
condition: formData.condition,
stock: formData.stock,
image_url: formData.image_url || undefined,
attributes: attrs,
printer_model_ids: showPrinterCompat ? selectedPrinterIds : undefined,
});
};
return (
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
<h2 className="text-xl font-bold mb-4 text-white">
{product ? 'Izmeni proizvod' : 'Dodaj proizvod'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Naziv</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Opis</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena (RSD)</label>
<input
type="number"
name="price"
value={formData.price}
onChange={handleChange}
min="0"
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Stanje</label>
<select
name="condition"
value={formData.condition}
onChange={handleChange}
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="new">Novo</option>
<option value="used_like_new">Korisceno - kao novo</option>
<option value="used_good">Korisceno - dobro</option>
<option value="used_fair">Korisceno - pristojno</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Kolicina</label>
<input
type="number"
name="stock"
value={formData.stock}
onChange={handleChange}
min="0"
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">URL slike</label>
<input
type="url"
name="image_url"
value={formData.image_url}
onChange={handleChange}
placeholder="https://..."
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Atributi (JSON)</label>
<textarea
name="attributes"
value={formData.attributes}
onChange={handleChange}
rows={3}
placeholder='{"key": "value"}'
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Printer Compatibility */}
{showPrinterCompat && printerModels.length > 0 && (
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2 text-white/60">Kompatibilni stampaci</label>
<div className="flex flex-wrap gap-2">
{printerModels.map(model => (
<button
key={model.id}
type="button"
onClick={() => togglePrinter(model.id)}
className={`px-3 py-1 rounded text-sm transition-colors ${
selectedPrinterIds.includes(model.id)
? 'bg-blue-600 text-white'
: 'bg-white/[0.06] text-white/60 hover:bg-white/[0.08]'
}`}
>
{model.name}
</button>
))}
</div>
</div>
)}
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,460 @@
'use client';
import { useState, useEffect } from 'react';
import { analyticsService, filamentService, productService } from '@/src/services/api';
import { InventoryStats, SalesStats, Product } from '@/src/types/product';
import { Filament } from '@/src/types/filament';
type Tab = 'inventar' | 'prodaja' | 'posetioci' | 'nabavka';
const CATEGORY_LABELS: Record<string, string> = {
printer: 'Stampaci',
build_plate: 'Ploce',
nozzle: 'Mlaznice',
spare_part: 'Delovi',
accessory: 'Oprema',
};
export default function AnalitikaPage() {
const [activeTab, setActiveTab] = useState<Tab>('inventar');
const [inventoryStats, setInventoryStats] = useState<InventoryStats | null>(null);
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [inv, sales, fils, prods] = await Promise.all([
analyticsService.getInventory().catch(() => null),
analyticsService.getSales().catch(() => null),
filamentService.getAll().catch(() => []),
productService.getAll().catch(() => []),
]);
setInventoryStats(inv);
setSalesStats(sales);
setFilaments(fils);
setProducts(prods);
} catch (error) {
console.error('Error loading analytics:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const tabs: { key: Tab; label: string }[] = [
{ key: 'inventar', label: 'Inventar' },
{ key: 'prodaja', label: 'Prodaja' },
{ key: 'posetioci', label: 'Posetioci' },
{ key: 'nabavka', label: 'Nabavka' },
];
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje analitike...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Analitika</h1>
<p className="text-white/40 mt-1">Pregled stanja inventara i prodaje</p>
</div>
{/* Tabs */}
<div className="flex border-b border-white/[0.06]">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-500 text-blue-400'
: 'border-transparent text-white/40 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Inventar Tab */}
{activeTab === 'inventar' && (
<div className="space-y-6">
{inventoryStats ? (
<>
{/* Filament stats */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Filamenti</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">SKU-ovi</p>
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Refili</p>
<p className="text-2xl font-bold text-green-400">{inventoryStats.filaments.total_refills}</p>
</div>
<div>
<p className="text-sm text-white/40">Spulne</p>
<p className="text-2xl font-bold text-blue-400">{inventoryStats.filaments.total_spools}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.filaments.out_of_stock}</p>
</div>
<div>
<p className="text-sm text-white/40">Vrednost inventara</p>
<p className="text-2xl font-bold text-yellow-400">
{inventoryStats.filaments.inventory_value.toLocaleString('sr-RS')} RSD
</p>
</div>
</div>
</div>
{/* Product stats */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">SKU-ovi</p>
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.products.out_of_stock}</p>
</div>
<div>
<p className="text-sm text-white/40">Vrednost inventara</p>
<p className="text-2xl font-bold text-yellow-400">
{inventoryStats.products.inventory_value.toLocaleString('sr-RS')} RSD
</p>
</div>
</div>
{/* Category breakdown */}
{inventoryStats.products.by_category && Object.keys(inventoryStats.products.by_category).length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-white/40 mb-2">Po kategoriji</h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{Object.entries(inventoryStats.products.by_category).map(([cat, count]) => (
<div key={cat} className="bg-white/[0.06] rounded p-3">
<p className="text-xs text-white/40">{CATEGORY_LABELS[cat] || cat}</p>
<p className="text-lg font-bold text-white">{count}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Combined */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Ukupno</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-white/40">Ukupno SKU-ova</p>
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.combined.out_of_stock}</p>
</div>
</div>
</div>
</>
) : (
<div className="bg-white/[0.04] rounded-2xl p-6">
<p className="text-white/40">Podaci o inventaru nisu dostupni. Proverite API konekciju.</p>
{/* Fallback from direct data */}
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">Filamenata</p>
<p className="text-2xl font-bold text-white">{filaments.length}</p>
</div>
<div>
<p className="text-sm text-white/40">Proizvoda</p>
<p className="text-2xl font-bold text-white">{products.length}</p>
</div>
<div>
<p className="text-sm text-white/40">Nisko stanje (fil.)</p>
<p className="text-2xl font-bold text-yellow-400">
{filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0).length}
</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju (fil.)</p>
<p className="text-2xl font-bold text-red-400">
{filaments.filter(f => f.kolicina === 0).length}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Prodaja Tab */}
{activeTab === 'prodaja' && (
<div className="space-y-6">
{salesStats ? (
<>
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-2">Aktivni popusti</h3>
<p className="text-3xl font-bold text-purple-400">{salesStats.total_active_sales}</p>
</div>
{salesStats.filament_sales.length > 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Filamenti na popustu ({salesStats.filament_sales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{salesStats.filament_sales.map(sale => (
<tr key={sale.id}>
<td className="px-3 py-2 text-white/90">{sale.name}</td>
<td className="px-3 py-2 text-purple-300">-{sale.sale_percentage}%</td>
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-white/40">
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{salesStats.product_sales.length > 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi na popustu ({salesStats.product_sales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
<th className="px-3 py-2 text-left text-white/60">Kategorija</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{salesStats.product_sales.map(sale => (
<tr key={sale.id}>
<td className="px-3 py-2 text-white/90">{sale.name}</td>
<td className="px-3 py-2 text-white/40">{CATEGORY_LABELS[sale.category] || sale.category}</td>
<td className="px-3 py-2 text-orange-300">-{sale.sale_percentage}%</td>
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-white/40">
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{salesStats.filament_sales.length === 0 && salesStats.product_sales.length === 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6 text-center">
<p className="text-white/40">Nema aktivnih popusta</p>
</div>
)}
</>
) : (
<div className="bg-white/[0.04] rounded-2xl p-6">
<p className="text-white/40">Podaci o prodaji nisu dostupni. Proverite API konekciju.</p>
<div className="mt-4">
<p className="text-sm text-white/30">
Filamenti sa popustom: {filaments.filter(f => f.sale_active).length}
</p>
<p className="text-sm text-white/30">
Proizvodi sa popustom: {products.filter(p => p.sale_active).length}
</p>
</div>
</div>
)}
</div>
)}
{/* Posetioci Tab */}
{activeTab === 'posetioci' && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Analitika posetilaca</h3>
<p className="text-white/40 mb-4">
Matomo analitika je dostupna na eksternom dashboardu.
</p>
<a
href="https://analytics.demirix.dev"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Otvori Matomo analitiku
</a>
<p className="text-xs text-white/30 mt-3">
analytics.demirix.dev - Site ID: 7
</p>
</div>
)}
{/* Nabavka Tab */}
{activeTab === 'nabavka' && (
<div className="space-y-6">
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Preporuke za nabavku</h3>
<p className="text-white/40 text-sm mb-4">Na osnovu trenutnog stanja inventara</p>
{/* Critical (out of stock) */}
{(() => {
const critical = filaments.filter(f => f.kolicina === 0);
return critical.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-red-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-red-500 inline-block" />
Kriticno - nema na stanju ({critical.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Tip</th>
<th className="px-3 py-2 text-left text-white/60">Finis</th>
<th className="px-3 py-2 text-left text-white/60">Boja</th>
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{critical.map(f => (
<tr key={f.id}>
<td className="px-3 py-2 text-white/90">{f.tip}</td>
<td className="px-3 py-2 text-white/60">{f.finish}</td>
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
{f.boja}
</td>
<td className="px-3 py-2 text-red-400 font-bold">0</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null;
})()}
{/* Warning (low stock) */}
{(() => {
const warning = filaments.filter(f => f.kolicina > 0 && f.kolicina <= 2);
return warning.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-yellow-500 inline-block" />
Upozorenje - nisko stanje ({warning.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Tip</th>
<th className="px-3 py-2 text-left text-white/60">Finis</th>
<th className="px-3 py-2 text-left text-white/60">Boja</th>
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{warning.map(f => (
<tr key={f.id}>
<td className="px-3 py-2 text-white/90">{f.tip}</td>
<td className="px-3 py-2 text-white/60">{f.finish}</td>
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
{f.boja}
</td>
<td className="px-3 py-2 text-yellow-400 font-bold">{f.kolicina}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null;
})()}
{/* OK */}
{(() => {
const ok = filaments.filter(f => f.kolicina > 2);
return (
<div>
<h4 className="text-sm font-medium text-green-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-green-500 inline-block" />
Dobro stanje ({ok.length})
</h4>
<p className="text-sm text-white/30">{ok.length} filamenata ima dovoljno na stanju (3+)</p>
</div>
);
})()}
{/* Product restock */}
{(() => {
const lowProducts = products.filter(p => p.stock <= 2);
return lowProducts.length > 0 ? (
<div className="mt-6">
<h4 className="text-sm font-medium text-orange-400 mb-2">Proizvodi za nabavku ({lowProducts.length})</h4>
<div className="space-y-1">
{lowProducts.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="text-white/60">{p.name}</span>
<span className={p.stock === 0 ? 'text-red-400 font-bold' : 'text-yellow-400'}>
{p.stock}
</span>
</div>
))}
</div>
</div>
) : null;
})()}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,448 @@
'use client'
import { useState, useEffect } from 'react';
import { colorService } from '@/src/services/api';
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
import '@/src/styles/select.css';
interface Color {
id: string;
name: string;
hex: string;
cena_refill?: number;
cena_spulna?: number;
createdAt?: string;
updatedAt?: string;
}
export default function BojePage() {
const [colors, setColors] = useState<Color[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingColor, setEditingColor] = useState<Color | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedColors, setSelectedColors] = useState<Set<string>>(new Set());
// Fetch colors
const fetchColors = async () => {
try {
setLoading(true);
const colors = await colorService.getAll();
setColors(colors.sort((a: Color, b: Color) => a.name.localeCompare(b.name)));
} catch (err) {
setError('Greska pri ucitavanju boja');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchColors();
}, []);
const handleSave = async (color: Partial<Color>) => {
try {
if (color.id) {
await colorService.update(color.id, {
name: color.name!,
hex: color.hex!,
cena_refill: color.cena_refill,
cena_spulna: color.cena_spulna
});
} else {
await colorService.create({
name: color.name!,
hex: color.hex!,
cena_refill: color.cena_refill,
cena_spulna: color.cena_spulna
});
}
setEditingColor(null);
setShowAddForm(false);
fetchColors();
} catch (err) {
setError('Greska pri cuvanju boje');
console.error('Save error:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovu boju?')) {
return;
}
try {
await colorService.delete(id);
fetchColors();
} catch (err) {
setError('Greska pri brisanju boje');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedColors.size === 0) {
setError('Molimo izaberite boje za brisanje');
return;
}
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedColors.size} boja?`)) {
return;
}
try {
await Promise.all(Array.from(selectedColors).map(id => colorService.delete(id)));
setSelectedColors(new Set());
fetchColors();
} catch (err) {
setError('Greska pri brisanju boja');
console.error('Bulk delete error:', err);
}
};
const toggleColorSelection = (colorId: string) => {
const newSelection = new Set(selectedColors);
if (newSelection.has(colorId)) {
newSelection.delete(colorId);
} else {
newSelection.add(colorId);
}
setSelectedColors(newSelection);
};
const toggleSelectAll = () => {
const filteredColors = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
);
const allFilteredSelected = filteredColors.every(c => selectedColors.has(c.id));
if (allFilteredSelected) {
setSelectedColors(new Set());
} else {
setSelectedColors(new Set(filteredColors.map(c => c.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Upravljanje bojama</h1>
<p className="text-white/40 mt-1">{colors.length} boja ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingColor && (
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Dodaj novu boju
</button>
)}
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
{selectedColors.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Obrisi izabrane ({selectedColors.size})
</button>
)}
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Add Form */}
{showAddForm && (
<ColorForm
color={{}}
onSave={handleSave}
onCancel={() => {
setShowAddForm(false);
}}
/>
)}
{/* Search Bar */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi boje..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" 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>
{/* Colors Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={(() => {
const filtered = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
);
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
})()}
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-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{colors
.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((color) => (
<tr key={color.id} className="hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedColors.has(color.id)}
onChange={() => toggleColorSelection(color.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-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-10 h-10 rounded border-2 border-white/[0.08]"
style={{ backgroundColor: color.hex }}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.hex}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-400">
{(color.cena_refill || 3499).toLocaleString('sr-RS')} RSD
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-blue-400">
{(color.cena_spulna || 3999).toLocaleString('sr-RS')} RSD
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => setEditingColor(color)}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(color.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Edit Modal */}
{editingColor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white/[0.04] rounded-2xl shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="p-6">
<h3 className="text-xl font-bold mb-4 text-white">Izmeni boju</h3>
<ColorForm
color={editingColor}
onSave={(color) => {
handleSave(color);
setEditingColor(null);
}}
onCancel={() => setEditingColor(null)}
isModal={true}
/>
</div>
</div>
</div>
)}
</div>
);
}
// Color Form Component
function ColorForm({
color,
onSave,
onCancel,
isModal = false
}: {
color: Partial<Color>,
onSave: (color: Partial<Color>) => void,
onCancel: () => void,
isModal?: boolean
}) {
const [formData, setFormData] = useState({
name: color.name || '',
hex: color.hex || '#000000',
cena_refill: color.cena_refill || 3499,
cena_spulna: color.cena_spulna || 3999,
});
const isBambuLabColor = !!(formData.name && Object.prototype.hasOwnProperty.call(bambuLabColors, formData.name));
const bambuHex = formData.name ? getColorHex(formData.name) : null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type } = e.target;
setFormData({
...formData,
[name]: type === 'number' ? parseInt(value) || 0 : value
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const hexToSave = isBambuLabColor && bambuHex ? bambuHex : formData.hex;
onSave({
...color,
name: formData.name,
hex: hexToSave,
cena_refill: formData.cena_refill,
cena_spulna: formData.cena_spulna
});
};
return (
<div className={isModal ? "" : "p-6 bg-white/[0.04] rounded-2xl shadow"}>
{!isModal && (
<h2 className="text-xl font-bold mb-4 text-white">
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
</h2>
)}
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Naziv boje</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
placeholder="npr. Crvena"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Hex kod boje</label>
<div className="flex gap-2 items-center">
<input
type="color"
name="hex"
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
disabled={isBambuLabColor}
className={`w-12 h-12 p-1 border-2 border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
/>
<input
type="text"
name="hex"
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
required
readOnly={isBambuLabColor}
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#000000"
className={`flex-1 px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-[#060a14] cursor-not-allowed' : ''}`}
/>
</div>
{isBambuLabColor && (
<p className="text-xs text-white/40 mt-1">
Bambu Lab predefinisana boja - hex kod se ne moze menjati
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena Refil</label>
<input
type="number"
name="cena_refill"
value={formData.cena_refill}
onChange={handleChange}
required
min="0"
step="1"
placeholder="3499"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena Spulna</label>
<input
type="number"
name="cena_spulna"
value={formData.cena_spulna}
onChange={handleChange}
required
min="0"
step="1"
placeholder="3999"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,990 @@
'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';
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<string, string[]> = {
'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 FilamentiPage() {
const router = useRouter();
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [sortField, setSortField] = useState<string>('boja');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [selectedFilaments, setSelectedFilaments] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState('');
const [availableColors, setAvailableColors] = useState<Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>>([]);
// Fetch filaments
const fetchFilaments = async () => {
try {
setLoading(true);
const filaments = await filamentService.getAll();
setFilaments(filaments);
} catch (err) {
setError('Greska pri ucitavanju 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<FilamentWithId>) => {
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 = 'Greska pri cuvanju 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 zelite obrisati ovaj filament?')) {
return;
}
try {
await filamentService.delete(id);
fetchAllData();
} catch (err) {
setError('Greska 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 zelite 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('Greska 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)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Filamenti</h1>
<p className="text-white/40 mt-1">{filaments.length} filamenata ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingFilament && (
<button
onClick={() => {
setShowAddForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="px-3 sm:px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm sm:text-base"
>
Dodaj novi
</button>
)}
{selectedFilaments.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-3 sm:px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm sm:text-base"
>
Obrisi izabrane ({selectedFilaments.size})
</button>
)}
<SaleManager
filaments={filaments}
selectedFilaments={selectedFilaments}
onSaleUpdate={fetchFilaments}
/>
<BulkFilamentPriceEditor
filaments={filaments}
onUpdate={fetchFilaments}
/>
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Search Bar and Sorting */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi po tipu, finishu, boji ili ceni..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" 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>
{/* Sort Dropdown */}
<div>
<select
value={`${sortField || 'boja'}-${sortOrder || 'asc'}`}
onChange={(e) => {
try {
const [field, order] = e.target.value.split('-');
if (field && order) {
setSortField(field);
setSortOrder(order as 'asc' | 'desc');
}
} catch (error) {
console.error('Sort change error:', error);
}
}}
className="custom-select w-full px-3 py-2 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-md focus:outline-none focus:border-blue-500"
>
<option value="boja-asc">Sortiraj po: Boja (A-Z)</option>
<option value="boja-desc">Sortiraj po: Boja (Z-A)</option>
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finis (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finis (Z-A)</option>
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
<option value="updated_at-desc">Sortiraj po: Poslednje azurirano</option>
<option value="updated_at-asc">Sortiraj po: Prvo azurirano</option>
<option value="kolicina-desc">Sortiraj po: Kolicina (visoka-niska)</option>
<option value="kolicina-asc">Sortiraj po: Kolicina (niska-visoka)</option>
</select>
</div>
</div>
{/* Add/Edit Form */}
{(showAddForm || editingFilament) && (
<div>
<FilamentForm
key={editingFilament?.id || 'new'}
filament={editingFilament || {}}
filaments={filaments}
availableColors={availableColors}
onSave={handleSave}
onCancel={() => {
setEditingFilament(null);
setShowAddForm(false);
}}
/>
</div>
)}
{/* Filaments Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={filteredAndSortedFilaments.length > 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-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th onClick={() => handleSort('tip')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Finis {sortField === 'finish' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Kolicina {sortField === 'kolicina' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('cena')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Cena {sortField === 'cena' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Popust</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{filteredAndSortedFilaments.map((filament) => (
<tr key={filament.id} className="hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedFilaments.has(filament.id)}
onChange={() => 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-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.tip}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.finish}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
<div className="flex items-center gap-2">
{filament.boja_hex && (
<div
className="w-7 h-7 rounded border border-white/[0.08]"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span>{filament.boja}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
{filament.refill > 0 ? (
<span className="text-green-400 font-bold">{filament.refill}</span>
) : (
<span className="text-white/30">0</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
{filament.spulna > 0 ? (
<span className="text-blue-400 font-bold">{filament.spulna}</span>
) : (
<span className="text-white/30">0</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.kolicina}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-white/90">
{(() => {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
if (!hasRefill && !hasSpool) return '-';
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
} else {
const colorData = availableColors.find(c => c.name === filament.boja);
refillPrice = colorData?.cena_refill || 3499;
spoolPrice = colorData?.cena_spulna || 3999;
}
return (
<>
{hasRefill && (
<span className="text-green-400">
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span className="text-blue-400">
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-white/40">RSD</span>
</>
);
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{filament.sale_active && filament.sale_percentage ? (
<div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
-{filament.sale_percentage}%
</span>
{filament.sale_end_date && (
<div className="text-xs text-white/40 mt-1">
do {new Date(filament.sale_end_date).toLocaleDateString('sr-RS')}
</div>
)}
</div>
) : (
<span className="text-white/30">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => {
setEditingFilament(filament);
setShowAddForm(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(filament.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Filament Form Component
function FilamentForm({
filament,
filaments,
availableColors,
onSave,
onCancel
}: {
filament: Partial<FilamentWithId>,
filaments: FilamentWithId[],
availableColors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
onSave: (filament: Partial<FilamentWithId>) => void,
onCancel: () => void
}) {
const [formData, setFormData] = useState({
tip: filament.tip || (filament.id ? '' : 'PLA'),
finish: filament.finish || (filament.id ? '' : 'Basic'),
boja: filament.boja || '',
boja_hex: filament.boja_hex || '',
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0),
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0),
kolicina: filament.kolicina || 0,
cena: '',
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(() => {
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;
}
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'),
finish: filament.finish || (filament.id ? '' : 'Basic'),
boja: filament.boja || '',
boja_hex: filament.boja_hex || '',
refill: filament.refill || 0,
spulna: filament.spulna || 0,
kolicina: filament.kolicina || 0,
cena: filament.cena || '',
cena_refill: refillPrice || 3499,
cena_spulna: spulnaPrice || 3999,
});
setIsInitialLoad(true);
}, [filament]);
// Update prices when color selection changes (but not on initial load)
useEffect(() => {
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<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name === 'refill' || name === 'spulna' || name === 'cena_refill' || name === 'cena_spulna') {
const numValue = parseInt(value) || 0;
setFormData({
...formData,
[name]: numValue
});
} else if (name === 'tip') {
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
const resetFinish = !newTypeFinishes.includes(formData.finish);
const spoolOnly = isSpoolOnly(formData.finish, value);
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') {
const refillOnly = isRefillOnly(value, formData.finish, formData.tip);
setFormData({
...formData,
[name]: value,
...(refillOnly ? { spulna: 0 } : {})
});
} else if (name === 'finish') {
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
const spoolOnly = isSpoolOnly(value, formData.tip);
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();
const totalQuantity = formData.refill + formData.spulna;
if (totalQuantity === 0) {
alert('Kolicina mora biti veca od 0. Dodajte refill ili spulna.');
return;
}
const refillPrice = formData.cena_refill;
const spoolPrice = formData.cena_spulna;
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';
}
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 (
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
<h2 className="text-xl font-bold mb-4 text-white">
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Tip</label>
<select
name="tip"
value={formData.tip}
onChange={handleChange}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi tip</option>
<option value="ABS">ABS</option>
<option value="ASA">ASA</option>
<option value="PA6">PA6</option>
<option value="PAHT">PAHT</option>
<option value="PC">PC</option>
<option value="PET">PET</option>
<option value="PETG">PETG</option>
<option value="PLA">PLA</option>
<option value="PPA">PPA</option>
<option value="PPS">PPS</option>
<option value="TPU">TPU</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Finis</label>
<select
name="finish"
value={formData.finish}
onChange={handleChange}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi finis</option>
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Boja</label>
<select
name="boja"
value={formData.boja}
onChange={(e) => {
const selectedColorName = e.target.value;
let hexValue = formData.boja_hex;
const dbColor = availableColors.find(c => c.name === selectedColorName);
if (dbColor) {
hexValue = dbColor.hex;
}
handleChange({
target: {
name: 'boja',
value: selectedColorName
}
} as any);
setFormData(prev => ({
...prev,
boja_hex: hexValue,
cena_refill: dbColor?.cena_refill || prev.cena_refill || 3499,
cena_spulna: dbColor?.cena_spulna || prev.cena_spulna || 3999
}));
}}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberite boju</option>
{getFilteredColors(availableColors, formData.tip, formData.finish).map(color => (
<option key={color.id} value={color.name}>
{color.name}
</option>
))}
<option value="custom">Druga boja...</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
{formData.boja && formData.boja !== 'custom' ? `Hex kod za ${formData.boja}` : 'Hex kod boje'}
</label>
<div className="flex items-center gap-2">
<input
type="color"
name="boja_hex"
value={formData.boja_hex || '#000000'}
onChange={handleChange}
disabled={false}
className="w-full h-10 px-1 py-1 border border-white/[0.08] rounded-md bg-white/[0.06] cursor-pointer"
/>
{formData.boja_hex && (
<span className="text-sm text-white/40">{formData.boja_hex}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
<span className="text-green-400">Cena Refila</span>
</label>
<input
type="number"
name="cena_refill"
value={formData.cena_refill || availableColors.find(c => 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-white/[0.08] rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
<span className="text-blue-400">Cena Spulne</span>
</label>
<input
type="number"
name="cena_spulna"
value={formData.cena_spulna || availableColors.find(c => 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-white/[0.08] rounded-md ${
isRefillOnly(formData.boja, formData.finish)
? 'bg-white/[0.08] cursor-not-allowed text-white/40'
: 'bg-white/[0.06] text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
}`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
Refil
{isSpoolOnly(formData.finish, formData.tip) && (
<span className="text-xs text-white/40 ml-2">(samo spulna postoji)</span>
)}
</label>
<input
type="number"
name="refill"
value={formData.refill}
onChange={handleChange}
min="0"
step="1"
placeholder="0"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
Spulna
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
<span className="text-xs text-white/40 ml-2">(samo refil postoji)</span>
)}
</label>
<input
type="number"
name="spulna"
value={formData.spulna}
onChange={handleChange}
min="0"
step="1"
placeholder="0"
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Ukupna kolicina</label>
<input
type="number"
name="kolicina"
value={formData.refill + formData.spulna}
readOnly
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.08] text-white/90 cursor-not-allowed"
/>
</div>
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { AdminLayout } from '@/src/components/layout/AdminLayout';
import { usePathname } from 'next/navigation';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return <AdminLayout currentPath={pathname}>{children}</AdminLayout>;
}

View File

@@ -0,0 +1,249 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { filamentService, productService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { Product } from '@/src/types/product';
interface StatsCard {
label: string;
value: number | string;
colorHex: string;
href?: string;
}
export default function AdminOverview() {
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [filamentData, productData] = await Promise.all([
filamentService.getAll(),
productService.getAll().catch(() => []),
]);
setFilaments(filamentData);
setProducts(productData);
} catch (error) {
console.error('Error loading overview data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const lowStockFilaments = filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0);
const outOfStockFilaments = filaments.filter(f => f.kolicina === 0);
const lowStockProducts = products.filter(p => p.stock <= 2 && p.stock > 0);
const outOfStockProducts = products.filter(p => p.stock === 0);
const activeSales = filaments.filter(f => f.sale_active).length + products.filter(p => p.sale_active).length;
const statsCards: StatsCard[] = [
{ label: 'Ukupno filamenata', value: filaments.length, colorHex: '#3b82f6', href: '/upadaj/dashboard/filamenti' },
{ label: 'Ukupno proizvoda', value: products.length, colorHex: '#22c55e', href: '/upadaj/dashboard/stampaci' },
{ label: 'Nisko stanje', value: lowStockFilaments.length + lowStockProducts.length, colorHex: '#f59e0b' },
{ label: 'Aktivni popusti', value: activeSales, colorHex: '#a855f7', href: '/upadaj/dashboard/prodaja' },
];
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-white/40 text-sm">Ucitavanje...</p>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Page title */}
<div>
<h1
className="text-2xl font-black text-white tracking-tight"
style={{ fontFamily: 'var(--font-display)' }}
>
Pregled
</h1>
<p className="text-white/40 mt-1 text-sm">Brzi pregled stanja inventara</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statsCards.map((card) => {
const content = (
<div
key={card.label}
className="rounded-2xl p-5 text-white"
style={{
background: `linear-gradient(135deg, ${card.colorHex}, ${card.colorHex}cc)`,
boxShadow: `0 4px 20px ${card.colorHex}30`,
}}
>
<p className="text-sm font-semibold opacity-80">{card.label}</p>
<p
className="text-3xl font-black mt-1"
style={{ fontFamily: 'var(--font-display)' }}
>
{card.value}
</p>
</div>
);
return card.href ? (
<Link key={card.label} href={card.href} className="hover:scale-[1.02] transition-transform">
{content}
</Link>
) : (
<div key={card.label}>{content}</div>
);
})}
</div>
{/* Low Stock Alerts */}
{(lowStockFilaments.length > 0 || outOfStockFilaments.length > 0 || lowStockProducts.length > 0 || outOfStockProducts.length > 0) && (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<h2
className="text-lg font-bold text-white mb-4"
style={{ fontFamily: 'var(--font-display)' }}
>
Upozorenja o stanju
</h2>
{/* Out of stock */}
{(outOfStockFilaments.length > 0 || outOfStockProducts.length > 0) && (
<div className="mb-4">
<h3 className="text-sm font-semibold text-red-400 mb-2">
Nema na stanju ({outOfStockFilaments.length + outOfStockProducts.length})
</h3>
<div className="space-y-1.5">
{outOfStockFilaments.slice(0, 5).map(f => (
<div key={f.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
{f.tip} {f.finish} - {f.boja}
</div>
))}
{outOfStockProducts.slice(0, 5).map(p => (
<div key={p.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
{p.name}
</div>
))}
{(outOfStockFilaments.length + outOfStockProducts.length) > 10 && (
<p className="text-xs text-white/30">
...i jos {outOfStockFilaments.length + outOfStockProducts.length - 10}
</p>
)}
</div>
</div>
)}
{/* Low stock */}
{(lowStockFilaments.length > 0 || lowStockProducts.length > 0) && (
<div>
<h3 className="text-sm font-semibold text-amber-400 mb-2">
Nisko stanje ({lowStockFilaments.length + lowStockProducts.length})
</h3>
<div className="space-y-1.5">
{lowStockFilaments.slice(0, 5).map(f => (
<div key={f.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
{f.tip} {f.finish} - {f.boja} (kolicina: {f.kolicina})
</div>
))}
{lowStockProducts.slice(0, 5).map(p => (
<div key={p.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
{p.name} (stanje: {p.stock})
</div>
))}
{(lowStockFilaments.length + lowStockProducts.length) > 10 && (
<p className="text-xs text-white/30">
...i jos {lowStockFilaments.length + lowStockProducts.length - 10}
</p>
)}
</div>
</div>
)}
</div>
)}
{/* Recent Activity */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<h2
className="text-lg font-bold text-white mb-4"
style={{ fontFamily: 'var(--font-display)' }}
>
Poslednje dodano
</h2>
<div className="space-y-2.5">
{[...filaments]
.sort((a, b) => {
const dateA = a.updated_at || a.created_at || '';
const dateB = b.updated_at || b.created_at || '';
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 5)
.map(f => (
<div key={f.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
{f.boja_hex && (
<div
className="w-4 h-4 rounded-md border border-white/10"
style={{ backgroundColor: f.boja_hex }}
/>
)}
<span className="text-white/70">{f.tip} {f.finish} - {f.boja}</span>
</div>
<span className="text-white/30 text-xs">
{f.updated_at
? new Date(f.updated_at).toLocaleDateString('sr-RS')
: f.created_at
? new Date(f.created_at).toLocaleDateString('sr-RS')
: '-'}
</span>
</div>
))}
{filaments.length === 0 && (
<p className="text-white/30 text-sm">Nema filamenata</p>
)}
</div>
</div>
{/* Quick Actions */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<h2
className="text-lg font-bold text-white mb-4"
style={{ fontFamily: 'var(--font-display)' }}
>
Brze akcije
</h2>
<div className="flex flex-wrap gap-3">
{[
{ href: '/upadaj/dashboard/filamenti', label: 'Dodaj filament', color: '#3b82f6' },
{ href: '/upadaj/dashboard/stampaci', label: 'Dodaj proizvod', color: '#22c55e' },
{ href: '/upadaj/dashboard/prodaja', label: 'Upravljaj popustima', color: '#a855f7' },
{ href: '/upadaj/dashboard/boje', label: 'Upravljaj bojama', color: '#ec4899' },
{ href: '/upadaj/dashboard/analitika', label: 'Analitika', color: '#14b8a6' },
].map(action => (
<Link
key={action.href}
href={action.href}
className="px-4 py-2.5 text-white rounded-xl text-sm font-semibold hover:scale-[1.03] transition-transform"
style={{
background: action.color,
boxShadow: `0 2px 10px ${action.color}30`,
}}
>
{action.label}
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,360 @@
'use client';
import { useState, useEffect } from 'react';
import { filamentService, productService, analyticsService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { Product, SalesStats } from '@/src/types/product';
export default function ProdajaPage() {
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Filament sale form
const [filamentSalePercentage, setFilamentSalePercentage] = useState(10);
const [filamentSaleEndDate, setFilamentSaleEndDate] = useState('');
const [filamentSaleEnabled, setFilamentSaleEnabled] = useState(true);
// Product sale form
const [productSalePercentage, setProductSalePercentage] = useState(10);
const [productSaleEndDate, setProductSaleEndDate] = useState('');
const [productSaleEnabled, setProductSaleEnabled] = useState(true);
const [savingFilamentSale, setSavingFilamentSale] = useState(false);
const [savingProductSale, setSavingProductSale] = useState(false);
const fetchData = async () => {
try {
setLoading(true);
const [filamentData, productData, salesData] = await Promise.all([
filamentService.getAll(),
productService.getAll().catch(() => []),
analyticsService.getSales().catch(() => null),
]);
setFilaments(filamentData);
setProducts(productData);
setSalesStats(salesData);
} catch (err) {
setError('Greska pri ucitavanju podataka');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const getCurrentDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
const handleFilamentBulkSale = async () => {
if (!confirm('Primeniti popust na SVE filamente?')) return;
setSavingFilamentSale(true);
try {
await filamentService.updateBulkSale({
salePercentage: filamentSalePercentage,
saleEndDate: filamentSaleEndDate || undefined,
enableSale: filamentSaleEnabled,
});
await fetchData();
} catch (err) {
setError('Greska pri azuriranju popusta za filamente');
console.error(err);
} finally {
setSavingFilamentSale(false);
}
};
const handleClearFilamentSales = async () => {
if (!confirm('Ukloniti SVE popuste sa filamenata?')) return;
setSavingFilamentSale(true);
try {
await filamentService.updateBulkSale({
salePercentage: 0,
enableSale: false,
});
await fetchData();
} catch (err) {
setError('Greska pri brisanju popusta');
console.error(err);
} finally {
setSavingFilamentSale(false);
}
};
const handleProductBulkSale = async () => {
if (!confirm('Primeniti popust na SVE proizvode?')) return;
setSavingProductSale(true);
try {
await productService.updateBulkSale({
salePercentage: productSalePercentage,
saleEndDate: productSaleEndDate || undefined,
enableSale: productSaleEnabled,
});
await fetchData();
} catch (err) {
setError('Greska pri azuriranju popusta za proizvode');
console.error(err);
} finally {
setSavingProductSale(false);
}
};
const handleClearProductSales = async () => {
if (!confirm('Ukloniti SVE popuste sa proizvoda?')) return;
setSavingProductSale(true);
try {
await productService.updateBulkSale({
salePercentage: 0,
enableSale: false,
});
await fetchData();
} catch (err) {
setError('Greska pri brisanju popusta');
console.error(err);
} finally {
setSavingProductSale(false);
}
};
const activeFilamentSales = filaments.filter(f => f.sale_active);
const activeProductSales = products.filter(p => p.sale_active);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Upravljanje popustima</h1>
<p className="text-white/40 mt-1">
{activeFilamentSales.length + activeProductSales.length} aktivnih popusta
</p>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Overview Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Filamenti sa popustom</p>
<p className="text-3xl font-bold text-purple-400 mt-1">{activeFilamentSales.length}</p>
<p className="text-xs text-white/30 mt-1">od {filaments.length} ukupno</p>
</div>
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Proizvodi sa popustom</p>
<p className="text-3xl font-bold text-orange-400 mt-1">{activeProductSales.length}</p>
<p className="text-xs text-white/30 mt-1">od {products.length} ukupno</p>
</div>
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Ukupno aktivnih</p>
<p className="text-3xl font-bold text-blue-400 mt-1">
{activeFilamentSales.length + activeProductSales.length}
</p>
</div>
</div>
{/* Filament Sale Controls */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Popusti na filamente</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
<input
type="number"
min="0"
max="100"
value={filamentSalePercentage}
onChange={(e) => setFilamentSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
<input
type="datetime-local"
value={filamentSaleEndDate}
onChange={(e) => setFilamentSaleEndDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filamentSaleEnabled}
onChange={(e) => setFilamentSaleEnabled(e.target.checked)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm text-white/60">
Aktivan: <span className={filamentSaleEnabled ? 'text-green-400' : 'text-white/30'}>{filamentSaleEnabled ? 'Da' : 'Ne'}</span>
</span>
</label>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleFilamentBulkSale}
disabled={savingFilamentSale}
className="px-4 py-2 bg-purple-600 text-white rounded-xl hover:bg-purple-700 disabled:opacity-50 text-sm"
>
{savingFilamentSale ? 'Cuvanje...' : 'Primeni na sve filamente'}
</button>
<button
onClick={handleClearFilamentSales}
disabled={savingFilamentSale}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
>
Ukloni sve popuste
</button>
</div>
</div>
{/* Product Sale Controls */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Popusti na proizvode</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
<input
type="number"
min="0"
max="100"
value={productSalePercentage}
onChange={(e) => setProductSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
<input
type="datetime-local"
value={productSaleEndDate}
onChange={(e) => setProductSaleEndDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={productSaleEnabled}
onChange={(e) => setProductSaleEnabled(e.target.checked)}
className="w-4 h-4 text-orange-600"
/>
<span className="text-sm text-white/60">
Aktivan: <span className={productSaleEnabled ? 'text-green-400' : 'text-white/30'}>{productSaleEnabled ? 'Da' : 'Ne'}</span>
</span>
</label>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleProductBulkSale}
disabled={savingProductSale}
className="px-4 py-2 bg-orange-600 text-white rounded-xl hover:bg-orange-700 disabled:opacity-50 text-sm"
>
{savingProductSale ? 'Cuvanje...' : 'Primeni na sve proizvode'}
</button>
<button
onClick={handleClearProductSales}
disabled={savingProductSale}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
>
Ukloni sve popuste
</button>
</div>
</div>
{/* Active Sales List */}
{(activeFilamentSales.length > 0 || activeProductSales.length > 0) && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Aktivni popusti</h2>
{activeFilamentSales.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-purple-400 mb-2">Filamenti ({activeFilamentSales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Filament</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{activeFilamentSales.map(f => (
<tr key={f.id} className="hover:bg-white/[0.06]">
<td className="px-3 py-2 text-white/90">{f.tip} {f.finish} - {f.boja}</td>
<td className="px-3 py-2">
<span className="text-purple-300 font-medium">-{f.sale_percentage}%</span>
</td>
<td className="px-3 py-2 text-white/40">
{f.sale_end_date
? new Date(f.sale_end_date).toLocaleDateString('sr-RS')
: 'Neograniceno'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeProductSales.length > 0 && (
<div>
<h3 className="text-sm font-medium text-orange-400 mb-2">Proizvodi ({activeProductSales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Proizvod</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{activeProductSales.map(p => (
<tr key={p.id} className="hover:bg-white/[0.06]">
<td className="px-3 py-2 text-white/90">{p.name}</td>
<td className="px-3 py-2">
<span className="text-orange-300 font-medium">-{p.sale_percentage}%</span>
</td>
<td className="px-3 py-2 text-white/40">
{p.sale_end_date
? new Date(p.sale_end_date).toLocaleDateString('sr-RS')
: 'Neograniceno'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,334 @@
'use client';
import React, { useState, useEffect } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequest {
id: string;
color_name: string;
material_type: string;
finish_type: string;
user_email: string;
user_phone: string;
user_name: string;
description: string;
reference_url: string;
status: 'pending' | 'approved' | 'rejected' | 'completed';
admin_notes: string;
request_count: number;
created_at: string;
updated_at: string;
processed_at: string;
processed_by: string;
}
export default function ZahteviPage() {
const [requests, setRequests] = useState<ColorRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
useEffect(() => {
fetchRequests();
}, []);
const fetchRequests = async () => {
try {
const data = await colorRequestService.getAll();
setRequests(data);
} catch (error) {
setError('Failed to fetch color requests');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleStatusUpdate = async (id: string) => {
try {
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
await fetchRequests();
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
} catch (error) {
setError('Failed to update request');
console.error('Error:', error);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this request?')) return;
try {
await colorRequestService.delete(id);
await fetchRequests();
} catch (error) {
setError('Failed to delete request');
console.error('Error:', error);
}
};
const getStatusBadge = (status: string) => {
const colors = {
pending: 'bg-yellow-900/30 text-yellow-300',
approved: 'bg-green-900/30 text-green-300',
rejected: 'bg-red-900/30 text-red-300',
completed: 'bg-blue-900/30 text-blue-300'
};
return colors[status as keyof typeof colors] || 'bg-white/[0.06] text-white/60';
};
const getStatusLabel = (status: string) => {
const labels = {
pending: 'Na cekanju',
approved: 'Odobreno',
rejected: 'Odbijeno',
completed: 'Zavrseno'
};
return labels[status as keyof typeof labels] || status;
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
const date = new Date(dateString);
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje zahteva za boje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-white">Zahtevi za Boje</h1>
<p className="text-white/40 mt-1">{requests.length} zahteva ukupno</p>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
<div className="bg-white/[0.04] rounded-2xl shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Boja
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Materijal/Finis
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Broj Zahteva
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Korisnik
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Datum
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{requests.map((request) => (
<tr key={request.id} className="hover:bg-white/[0.06]">
<td className="px-4 py-3">
<div>
<div className="font-medium text-white/90">{request.color_name}</div>
{request.description && (
<div className="text-sm text-white/40 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
href={request.reference_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline"
>
Pogledaj referencu
</a>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<div className="text-white/90">{request.material_type}</div>
{request.finish_type && (
<div className="text-white/40">{request.finish_type}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-900/30 text-purple-300">
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
</span>
</td>
<td className="px-4 py-3">
<div className="text-sm">
{request.user_email ? (
<a href={`mailto:${request.user_email}`} className="text-blue-400 hover:underline">
{request.user_email}
</a>
) : (
<span className="text-white/30">Anonimno</span>
)}
{request.user_phone && (
<div className="mt-1">
<a href={`tel:${request.user_phone}`} className="text-blue-400 hover:underline">
{request.user_phone}
</a>
</div>
)}
</div>
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-y-2">
<select
value={editForm.status}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
className="text-sm border rounded px-2 py-1 bg-white/[0.06] border-white/[0.08] text-white/90"
>
<option value="">Izaberi status</option>
<option value="pending">Na cekanju</option>
<option value="approved">Odobreno</option>
<option value="rejected">Odbijeno</option>
<option value="completed">Zavrseno</option>
</select>
<textarea
placeholder="Napomene..."
value={editForm.admin_notes}
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
className="text-sm border rounded px-2 py-1 w-full bg-white/[0.06] border-white/[0.08] text-white/90"
rows={2}
/>
</div>
) : (
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
{getStatusLabel(request.status)}
</span>
{request.admin_notes && (
<div className="text-xs text-white/30 mt-1">{request.admin_notes}</div>
)}
{request.processed_by && (
<div className="text-xs text-white/30 mt-1">
od {request.processed_by}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-white/40">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
<div className="text-xs text-white/30">
Obradjeno: {formatDate(request.processed_at)}
</div>
)}
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-x-2">
<button
onClick={() => handleStatusUpdate(request.id)}
className="text-green-400 hover:text-green-300 text-sm"
>
Sacuvaj
</button>
<button
onClick={() => {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-white/40 hover:text-white/60 text-sm"
>
Otkazi
</button>
</div>
) : (
<div className="space-x-2">
<button
onClick={() => {
setEditingId(request.id);
setEditForm({
status: request.status,
admin_notes: request.admin_notes || ''
});
}}
className="text-blue-400 hover:text-blue-300 text-sm"
>
Izmeni
</button>
<button
onClick={() => handleDelete(request.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Obrisi
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-white/40">
Nema zahteva za boje
</div>
)}
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Ukupno Zahteva</div>
<div className="text-2xl font-bold text-white/90">
{requests.length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Na Cekanju</div>
<div className="text-2xl font-bold text-yellow-400">
{requests.filter(r => r.status === 'pending').length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Odobreno</div>
<div className="text-2xl font-bold text-green-400">
{requests.filter(r => r.status === 'approved').length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Zavrseno</div>
<div className="text-2xl font-bold text-blue-400">
{requests.filter(r => r.status === 'completed').length}
</div>
</div>
</div>
</div>
);
}