All checks were successful
Deploy / deploy (push) Successful in 2m26s
- Add customers table (021) and sales/sale_items tables (022) migrations - Add customer CRUD, sale CRUD (transactional with auto inventory decrement/restore), and analytics API endpoints (overview, top sellers, revenue chart, inventory alerts) - Add sales page with NewSaleModal (customer autocomplete, multi-item form, color-based pricing, stock validation) and SaleDetailModal - Add customers page with search, inline editing, and purchase history - Add analytics dashboard with recharts (revenue line chart, top sellers bar, refill vs spulna pie chart, inventory alerts table with stockout estimates) - Add customerService, saleService, analyticsService to frontend API layer - Update sidebar navigation on all admin pages
440 lines
22 KiB
TypeScript
440 lines
22 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { analyticsService } from '@/src/services/api';
|
|
import type { AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts';
|
|
|
|
type Period = '7d' | '30d' | '90d' | '6m' | '1y' | 'all';
|
|
|
|
const PERIOD_LABELS: Record<Period, string> = {
|
|
'7d': '7d',
|
|
'30d': '30d',
|
|
'90d': '90d',
|
|
'6m': '6m',
|
|
'1y': '1y',
|
|
'all': 'Sve',
|
|
};
|
|
|
|
const PERIOD_GROUP: Record<Period, string> = {
|
|
'7d': 'day',
|
|
'30d': 'day',
|
|
'90d': 'week',
|
|
'6m': 'month',
|
|
'1y': 'month',
|
|
'all': 'month',
|
|
};
|
|
|
|
function formatRSD(value: number): string {
|
|
return new Intl.NumberFormat('sr-RS', {
|
|
style: 'currency',
|
|
currency: 'RSD',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
}
|
|
|
|
export default function AnalyticsDashboard() {
|
|
const router = useRouter();
|
|
const [mounted, setMounted] = useState(false);
|
|
const [darkMode, setDarkMode] = useState(false);
|
|
const [period, setPeriod] = useState<Period>('30d');
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
|
const [topSellers, setTopSellers] = useState<TopSeller[]>([]);
|
|
const [revenueData, setRevenueData] = useState<RevenueDataPoint[]>([]);
|
|
const [inventoryAlerts, setInventoryAlerts] = useState<InventoryAlert[]>([]);
|
|
const [typeBreakdown, setTypeBreakdown] = useState<TypeBreakdown[]>([]);
|
|
|
|
// Initialize dark mode
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
const saved = localStorage.getItem('darkMode');
|
|
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!mounted) return;
|
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
|
if (darkMode) document.documentElement.classList.add('dark');
|
|
else document.documentElement.classList.remove('dark');
|
|
}, [darkMode, mounted]);
|
|
|
|
// Check authentication
|
|
useEffect(() => {
|
|
if (!mounted) return;
|
|
const token = localStorage.getItem('authToken');
|
|
const expiry = localStorage.getItem('tokenExpiry');
|
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
|
window.location.href = '/upadaj';
|
|
}
|
|
}, [mounted]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError('');
|
|
const [overviewData, topSellersData, revenueChartData, alertsData, breakdownData] = await Promise.all([
|
|
analyticsService.getOverview(period),
|
|
analyticsService.getTopSellers(period),
|
|
analyticsService.getRevenueChart(period, PERIOD_GROUP[period]),
|
|
analyticsService.getInventoryAlerts(),
|
|
analyticsService.getTypeBreakdown(period),
|
|
]);
|
|
setOverview(overviewData);
|
|
setTopSellers(topSellersData);
|
|
setRevenueData(revenueChartData);
|
|
setInventoryAlerts(alertsData);
|
|
setTypeBreakdown(breakdownData);
|
|
} catch (err) {
|
|
setError('Greska pri ucitavanju analitike');
|
|
console.error('Analytics fetch error:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [period]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleLogout = () => {
|
|
localStorage.removeItem('authToken');
|
|
localStorage.removeItem('tokenExpiry');
|
|
router.push('/upadaj');
|
|
};
|
|
|
|
const PIE_COLORS = ['#22c55e', '#3b82f6'];
|
|
|
|
const getStockRowClass = (alert: InventoryAlert): string => {
|
|
if (alert.days_until_stockout === null) return '';
|
|
if (alert.days_until_stockout < 7) return 'bg-red-50 dark:bg-red-900/20';
|
|
if (alert.days_until_stockout < 14) return 'bg-yellow-50 dark:bg-yellow-900/20';
|
|
return 'bg-green-50 dark:bg-green-900/20';
|
|
};
|
|
|
|
if (!mounted) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
|
<div className="flex">
|
|
{/* Sidebar */}
|
|
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen sticky top-0">
|
|
<div className="p-6">
|
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
|
<nav className="space-y-2">
|
|
<a href="/dashboard" className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">Filamenti</a>
|
|
<a href="/upadaj/colors" className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">Boje</a>
|
|
<a href="/upadaj/requests" className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">Zahtevi za boje</a>
|
|
<a href="/upadaj/sales" className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">Prodaja</a>
|
|
<a href="/upadaj/customers" className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">Kupci</a>
|
|
<a href="/upadaj/analytics" className="block px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">Analitika</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1">
|
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-4">
|
|
<img
|
|
src="/logo.png"
|
|
alt="Filamenteka"
|
|
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
|
/>
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Analitika</h1>
|
|
</div>
|
|
<div className="flex gap-4 flex-wrap">
|
|
<button
|
|
onClick={() => router.push('/')}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
>
|
|
Nazad na sajt
|
|
</button>
|
|
{mounted && (
|
|
<button
|
|
onClick={() => setDarkMode(!darkMode)}
|
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
|
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
|
>
|
|
{darkMode ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleLogout}
|
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
|
>
|
|
Odjava
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{error && (
|
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Period Selector */}
|
|
<div className="mb-6 flex gap-2">
|
|
{(Object.keys(PERIOD_LABELS) as Period[]).map((p) => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPeriod(p)}
|
|
className={`px-4 py-2 rounded font-medium transition-colors ${
|
|
period === p
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 shadow'
|
|
}`}
|
|
>
|
|
{PERIOD_LABELS[p]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje analitike...</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Overview Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prihod</h3>
|
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{overview ? formatRSD(overview.revenue) : '-'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Broj prodaja</h3>
|
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{overview ? overview.sales_count : '-'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prosecna vrednost</h3>
|
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{overview ? formatRSD(overview.avg_order_value) : '-'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Jedinstveni kupci</h3>
|
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{overview ? overview.unique_customers : '-'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Revenue Chart */}
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-8">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Prihod po periodu</h2>
|
|
<div className="h-80">
|
|
{mounted && revenueData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={revenueData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
|
<XAxis
|
|
dataKey="period"
|
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
|
tick={{ fontSize: 12 }}
|
|
/>
|
|
<YAxis
|
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
|
tickFormatter={(value) => formatRSD(value)}
|
|
tick={{ fontSize: 12 }}
|
|
/>
|
|
<Tooltip
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
formatter={((value: number) => [formatRSD(value), 'Prihod']) as any}
|
|
contentStyle={{
|
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
|
borderRadius: '0.5rem',
|
|
color: darkMode ? '#f3f4f6' : '#111827',
|
|
}}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
dot={{ fill: '#3b82f6', r: 4 }}
|
|
activeDot={{ r: 6 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
|
Nema podataka za prikaz
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
|
{/* Top Sellers */}
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Najprodavanije boje</h2>
|
|
<div className="h-80">
|
|
{mounted && topSellers.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={topSellers} layout="vertical" margin={{ left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
|
<XAxis type="number" stroke={darkMode ? '#9ca3af' : '#6b7280'} tick={{ fontSize: 12 }} />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="boja"
|
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
|
tick={{ fontSize: 11 }}
|
|
width={120}
|
|
tickFormatter={(value: string, index: number) => {
|
|
const seller = topSellers[index];
|
|
return seller ? `${value} (${seller.tip})` : value;
|
|
}}
|
|
/>
|
|
<Tooltip
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
formatter={((value: number) => [value, 'Kolicina']) as any}
|
|
contentStyle={{
|
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
|
borderRadius: '0.5rem',
|
|
color: darkMode ? '#f3f4f6' : '#111827',
|
|
}}
|
|
/>
|
|
<Bar dataKey="total_qty" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
|
Nema podataka za prikaz
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Type Breakdown */}
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Refill vs Spulna</h2>
|
|
<div className="h-64">
|
|
{mounted && typeBreakdown.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={typeBreakdown}
|
|
dataKey="total_qty"
|
|
nameKey="item_type"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={80}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
label={({ name, percent }: any) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
|
>
|
|
{typeBreakdown.map((_, index) => (
|
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
formatter={((value: number, name: string) => [
|
|
`${value} kom | ${formatRSD(typeBreakdown.find(t => t.item_type === name)?.total_revenue ?? 0)}`,
|
|
name,
|
|
]) as any}
|
|
contentStyle={{
|
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
|
borderRadius: '0.5rem',
|
|
color: darkMode ? '#f3f4f6' : '#111827',
|
|
}}
|
|
/>
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
|
Nema podataka za prikaz
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inventory Alerts */}
|
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upozorenja za zalihe</h2>
|
|
{inventoryAlerts.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Boja</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tip</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Finish</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Refill</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Spulna</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Kolicina</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pros. dnevna prodaja</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dana do nestanka</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{inventoryAlerts.map((alert) => (
|
|
<tr key={alert.id} className={getStockRowClass(alert)}>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.boja}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.tip}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.finish}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.refill}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.spulna}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right font-medium">{alert.kolicina}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">
|
|
{alert.avg_daily_sales > 0 ? alert.avg_daily_sales.toFixed(1) : 'N/A'}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-right font-medium">
|
|
{alert.days_until_stockout !== null ? (
|
|
<span className={
|
|
alert.days_until_stockout < 7
|
|
? 'text-red-600 dark:text-red-400'
|
|
: alert.days_until_stockout < 14
|
|
? 'text-yellow-600 dark:text-yellow-400'
|
|
: 'text-green-600 dark:text-green-400'
|
|
}>
|
|
{Math.round(alert.days_until_stockout)}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-500 dark:text-gray-400">N/A</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 dark:text-gray-400">Sve zalihe su na zadovoljavajucem nivou.</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|