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,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>
);
}