- 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
461 lines
21 KiB
TypeScript
461 lines
21 KiB
TypeScript
'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>
|
|
);
|
|
}
|