Add sales tracking system with customers, analytics, and inventory management
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
This commit is contained in:
DaX
2026-03-04 23:58:57 +01:00
parent e9afe8bc35
commit ff6abdeef0
12 changed files with 2881 additions and 30 deletions

View File

@@ -0,0 +1,439 @@
'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>
);
}

View File

@@ -193,18 +193,12 @@ export default function ColorsManagement() {
<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 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
>
Boje
</a>
<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 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 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 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">Analitika</a>
</nav>
</div>
</div>

View File

@@ -0,0 +1,535 @@
'use client'
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { customerService } from '@/src/services/api';
import { Customer, Sale } from '@/src/types/sales';
interface CustomerWithSales extends Customer {
sales?: Sale[];
total_purchases?: number;
}
export default function CustomersManagement() {
const router = useRouter();
const [customers, setCustomers] = useState<CustomerWithSales[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [expandedCustomerId, setExpandedCustomerId] = useState<string | null>(null);
const [expandedSales, setExpandedSales] = useState<Sale[]>([]);
const [loadingSales, setLoadingSales] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [editForm, setEditForm] = useState<Partial<Customer>>({});
const [saving, setSaving] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
// Initialize dark mode
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
setDarkMode(JSON.parse(saved));
} else {
setDarkMode(true);
}
}, []);
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
// Check authentication
useEffect(() => {
if (!mounted) return;
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || Date.now() > parseInt(expiry)) {
window.location.href = '/upadaj';
}
}, [mounted]);
// Fetch customers
const fetchCustomers = useCallback(async () => {
try {
setLoading(true);
const data = await customerService.getAll();
setCustomers(data);
} catch (err) {
setError('Greska pri ucitavanju kupaca');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCustomers();
}, [fetchCustomers]);
// Search customers
const filteredCustomers = customers.filter((customer) => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
customer.name.toLowerCase().includes(term) ||
(customer.phone && customer.phone.toLowerCase().includes(term)) ||
(customer.city && customer.city.toLowerCase().includes(term))
);
});
// Toggle expanded row to show purchase history
const handleToggleExpand = async (customerId: string) => {
if (expandedCustomerId === customerId) {
setExpandedCustomerId(null);
setExpandedSales([]);
return;
}
setExpandedCustomerId(customerId);
setLoadingSales(true);
try {
const data = await customerService.getById(customerId);
setExpandedSales(data.sales || []);
} catch (err) {
console.error('Error fetching customer sales:', err);
setExpandedSales([]);
} finally {
setLoadingSales(false);
}
};
// Edit customer
const handleStartEdit = (customer: Customer) => {
setEditingCustomer(customer);
setEditForm({
name: customer.name,
phone: customer.phone || '',
city: customer.city || '',
notes: customer.notes || '',
});
};
const handleCancelEdit = () => {
setEditingCustomer(null);
setEditForm({});
};
const handleSaveEdit = async () => {
if (!editingCustomer) return;
setSaving(true);
try {
await customerService.update(editingCustomer.id, editForm);
setEditingCustomer(null);
setEditForm({});
await fetchCustomers();
} catch (err) {
setError('Greska pri cuvanju izmena');
console.error('Save error:', err);
} finally {
setSaving(false);
}
};
const handleLogout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
router.push('/upadaj');
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('sr-Latn-RS', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('sr-Latn-RS', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount) + ' RSD';
};
if (loading) {
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 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
>
Kupci
</a>
<a
href="/upadaj/analytics"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 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">
Upravljanje kupcima
</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' : '\u263D'}
</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}
<button
onClick={() => setError('')}
className="ml-4 text-red-600 dark:text-red-300 underline text-sm"
>
Zatvori
</button>
</div>
)}
{/* Search bar */}
<div className="mb-6">
<input
type="text"
placeholder="Pretrazi kupce..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full max-w-md px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Customer count */}
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Ukupno kupaca: {filteredCustomers.length}
</p>
{/* Customer table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Ime
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Telefon
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Grad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Beleske
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Datum registracije
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredCustomers.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-8 text-center text-gray-500 dark:text-gray-400"
>
{searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'}
</td>
</tr>
) : (
filteredCustomers.map((customer) => (
<>
<tr
key={customer.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
{editingCustomer?.id === customer.id ? (
<>
<td className="px-6 py-4">
<input
type="text"
value={editForm.name || ''}
onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4">
<input
type="text"
value={editForm.phone || ''}
onChange={(e) =>
setEditForm({ ...editForm, phone: e.target.value })
}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4">
<input
type="text"
value={editForm.city || ''}
onChange={(e) =>
setEditForm({ ...editForm, city: e.target.value })
}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4">
<textarea
value={editForm.notes || ''}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
rows={2}
placeholder="npr. stampa figurice, obicno crna..."
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDate(customer.created_at)}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
disabled={saving}
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 disabled:opacity-50"
>
{saving ? '...' : 'Sacuvaj'}
</button>
<button
onClick={handleCancelEdit}
className="px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
Otkazi
</button>
</div>
</td>
</>
) : (
<>
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
{customer.name}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{customer.phone || '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{customer.city || '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs whitespace-pre-wrap">
{customer.notes || <span className="text-gray-400 dark:text-gray-600 italic">Nema beleski</span>}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDate(customer.created_at)}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button
onClick={() => handleStartEdit(customer)}
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600"
>
Izmeni
</button>
<button
onClick={() => handleToggleExpand(customer.id)}
className={`px-3 py-1 text-sm rounded ${
expandedCustomerId === customer.id
? 'bg-indigo-600 text-white hover:bg-indigo-700'
: 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-800'
}`}
>
Istorija kupovina
</button>
</div>
</td>
</>
)}
</tr>
{/* Expanded purchase history */}
{expandedCustomerId === customer.id && (
<tr key={`${customer.id}-sales`}>
<td
colSpan={6}
className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"
>
<div className="ml-4">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Istorija kupovina - {customer.name}
</h4>
{loadingSales ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Ucitavanje...
</p>
) : expandedSales.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Nema evidentiranih kupovina
</p>
) : (
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded overflow-hidden">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
ID
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Datum
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Stavke
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Iznos
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Napomena
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{expandedSales.map((sale) => (
<tr key={sale.id}>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 font-mono">
{sale.id.substring(0, 8)}...
</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
{formatDate(sale.created_at)}
</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
{sale.item_count ?? '-'}
</td>
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white font-medium">
{formatCurrency(sale.total_amount)}
</td>
<td className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{sale.notes || '-'}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<td
colSpan={3}
className="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right"
>
Ukupno ({expandedSales.length} kupovina):
</td>
<td className="px-4 py-2 text-sm font-bold text-gray-900 dark:text-white">
{formatCurrency(
expandedSales.reduce(
(sum, sale) => sum + sale.total_amount,
0
)
)}
</td>
<td />
</tr>
</tfoot>
</table>
)}
</div>
</td>
</tr>
)}
</>
))
)}
</tbody>
</table>
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -123,19 +123,12 @@ export default function ColorRequestsAdmin() {
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
<div className="space-x-4">
<Link
href="/dashboard"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Inventar
</Link>
<Link
href="/upadaj/colors"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Boje
</Link>
<div className="space-x-4 flex flex-wrap gap-2">
<Link href="/dashboard" className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">Inventar</Link>
<Link href="/upadaj/colors" className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">Boje</Link>
<Link href="/upadaj/sales" className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">Prodaja</Link>
<Link href="/upadaj/customers" className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">Kupci</Link>
<Link href="/upadaj/analytics" className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">Analitika</Link>
</div>
</div>

869
app/upadaj/sales/page.tsx Normal file
View File

@@ -0,0 +1,869 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react';
import { saleService, customerService, filamentService, colorService } from '@/src/services/api';
import { Customer, Sale, SaleItem, CreateSaleRequest } from '@/src/types/sales';
import { Filament } from '@/src/types/filament';
interface LineItem {
filament_id: string;
item_type: 'refill' | 'spulna';
quantity: number;
}
export default function SalesPage() {
const [mounted, setMounted] = useState(false);
const [darkMode, setDarkMode] = useState(false);
// Sales list state
const [sales, setSales] = useState<Sale[]>([]);
const [totalSales, setTotalSales] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Modal state
const [showNewSaleModal, setShowNewSaleModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const LIMIT = 50;
// Dark mode init
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]);
// Auth check
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]);
// Fetch sales
const fetchSales = useCallback(async () => {
try {
setLoading(true);
setError('');
const data = await saleService.getAll(page, LIMIT);
setSales(data.sales);
setTotalSales(data.total);
} catch {
setError('Greska pri ucitavanju prodaja');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
if (!mounted) return;
fetchSales();
}, [mounted, fetchSales]);
const totalPages = Math.ceil(totalSales / LIMIT);
const handleViewDetail = async (sale: Sale) => {
try {
setDetailLoading(true);
setShowDetailModal(true);
const detail = await saleService.getById(sale.id);
setSelectedSale(detail);
} catch {
setError('Greska pri ucitavanju detalja prodaje');
setShowDetailModal(false);
} finally {
setDetailLoading(false);
}
};
const handleDeleteSale = async (id: string) => {
if (!window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) return;
try {
await saleService.delete(id);
setShowDetailModal(false);
setSelectedSale(null);
fetchSales();
} catch {
setError('Greska pri brisanju prodaje');
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('sr-RS', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const formatPrice = (amount: number) => {
return amount.toLocaleString('sr-RS') + ' RSD';
};
if (!mounted) return null;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 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 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 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 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
Analitika
</a>
</nav>
</div>
</div>
{/* Main Content */}
<div className="flex-1 p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Prodaja</h1>
<div className="flex items-center gap-4">
<button
onClick={() => setDarkMode(!darkMode)}
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
{darkMode ? 'Svetli mod' : 'Tamni mod'}
</button>
<button
onClick={() => setShowNewSaleModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors font-medium"
>
Nova prodaja
</button>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded">
{error}
</div>
)}
{/* Sales Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
) : sales.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Nema prodaja</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Datum
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Ime kupca
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Stavki
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Cena
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{sales.map((sale) => (
<tr key={sale.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{formatDate(sale.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{sale.customer_name || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{sale.item_count ?? 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{formatPrice(sale.total_amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
<button
onClick={() => handleViewDetail(sale)}
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Detalji
</button>
<button
onClick={() => handleDeleteSale(sale.id)}
className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Obrisi
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex justify-center items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Prethodna
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Strana {page} od {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Sledeca
</button>
</div>
)}
</div>
{/* New Sale Modal */}
{showNewSaleModal && (
<NewSaleModal
onClose={() => setShowNewSaleModal(false)}
onCreated={() => {
setShowNewSaleModal(false);
fetchSales();
}}
formatPrice={formatPrice}
/>
)}
{/* Sale Detail Modal */}
{showDetailModal && (
<SaleDetailModal
sale={selectedSale}
loading={detailLoading}
onClose={() => {
setShowDetailModal(false);
setSelectedSale(null);
}}
onDelete={(id) => handleDeleteSale(id)}
formatPrice={formatPrice}
formatDate={formatDate}
/>
)}
</div>
);
}
// --- NewSaleModal ---
function NewSaleModal({
onClose,
onCreated,
formatPrice,
}: {
onClose: () => void;
onCreated: () => void;
formatPrice: (n: number) => string;
}) {
const [customerName, setCustomerName] = useState('');
const [customerPhone, setCustomerPhone] = useState('');
const [customerCity, setCustomerCity] = useState('');
const [customerNotes, setCustomerNotes] = useState('');
const [notes, setNotes] = useState('');
const [items, setItems] = useState<LineItem[]>([{ filament_id: '', item_type: 'refill', quantity: 1 }]);
const [filaments, setFilaments] = useState<Filament[]>([]);
const [colorPrices, setColorPrices] = useState<Record<string, { cena_refill: number; cena_spulna: number }>>({});
const [customerSuggestions, setCustomerSuggestions] = useState<Customer[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const loadData = async () => {
try {
const [filamentData, colorData] = await Promise.all([
filamentService.getAll(),
colorService.getAll(),
]);
setFilaments(filamentData);
const priceMap: Record<string, { cena_refill: number; cena_spulna: number }> = {};
for (const c of colorData) {
priceMap[c.name] = { cena_refill: c.cena_refill || 3499, cena_spulna: c.cena_spulna || 3999 };
}
setColorPrices(priceMap);
} catch {
// Data failed to load
}
};
loadData();
}, []);
// Close suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleCustomerSearch = (value: string) => {
setCustomerName(value);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (value.length < 2) {
setCustomerSuggestions([]);
setShowSuggestions(false);
return;
}
searchTimeout.current = setTimeout(async () => {
try {
const results = await customerService.search(value);
setCustomerSuggestions(results);
setShowSuggestions(results.length > 0);
} catch {
setCustomerSuggestions([]);
setShowSuggestions(false);
}
}, 300);
};
const selectCustomer = (customer: Customer) => {
setCustomerName(customer.name);
setCustomerPhone(customer.phone || '');
setCustomerCity(customer.city || '');
setCustomerNotes(customer.notes || '');
setShowSuggestions(false);
};
const getFilamentById = (id: string): Filament | undefined => {
return filaments.find((f) => String(f.id) === String(id));
};
const getFilamentPrice = (filament: Filament, type: 'refill' | 'spulna'): number => {
const prices = colorPrices[filament.boja];
const base = type === 'refill' ? (prices?.cena_refill || 3499) : (prices?.cena_spulna || 3999);
if (filament.sale_active && filament.sale_percentage) {
return Math.round(base * (1 - filament.sale_percentage / 100));
}
return base;
};
const getItemPrice = (item: LineItem): number => {
const filament = getFilamentById(item.filament_id);
if (!filament) return 0;
return getFilamentPrice(filament, item.item_type) * item.quantity;
};
const totalAmount = items.reduce((sum, item) => sum + getItemPrice(item), 0);
const getAvailableStock = (filament: Filament, type: 'refill' | 'spulna'): number => {
return type === 'refill' ? filament.refill : filament.spulna;
};
const updateItem = (index: number, updates: Partial<LineItem>) => {
setItems((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...updates };
return next;
});
};
const removeItem = (index: number) => {
if (items.length <= 1) return;
setItems((prev) => prev.filter((_, i) => i !== index));
};
const addItem = () => {
setItems((prev) => [...prev, { filament_id: '', item_type: 'refill', quantity: 1 }]);
};
const handleSubmit = async () => {
if (!customerName.trim()) {
setSubmitError('Ime kupca je obavezno');
return;
}
const validItems = items.filter((item) => item.filament_id !== '' && item.quantity > 0);
if (validItems.length === 0) {
setSubmitError('Dodajte bar jednu stavku');
return;
}
// Validate stock
for (const item of validItems) {
const filament = getFilamentById(item.filament_id);
if (!filament) {
setSubmitError('Nevalidan filament');
return;
}
const available = getAvailableStock(filament, item.item_type);
if (item.quantity > available) {
setSubmitError(
`Nedovoljno zaliha za ${filament.boja} ${filament.tip} (${item.item_type}): dostupno ${available}, trazeno ${item.quantity}`
);
return;
}
}
const payload: CreateSaleRequest = {
customer: {
name: customerName.trim(),
phone: customerPhone.trim() || undefined,
city: customerCity.trim() || undefined,
notes: customerNotes.trim() || undefined,
},
items: validItems.map((item) => ({
filament_id: item.filament_id,
item_type: item.item_type,
quantity: item.quantity,
})),
notes: notes.trim() || undefined,
};
try {
setSubmitting(true);
setSubmitError('');
await saleService.create(payload);
onCreated();
} catch {
setSubmitError('Greska pri kreiranju prodaje');
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Nova prodaja</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
>
&times;
</button>
</div>
{submitError && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded text-sm">
{submitError}
</div>
)}
{/* Customer Section */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative" ref={suggestionsRef}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Ime kupca *
</label>
<input
type="text"
value={customerName}
onChange={(e) => handleCustomerSearch(e.target.value)}
onFocus={() => customerSuggestions.length > 0 && setShowSuggestions(true)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Ime i prezime"
/>
{showSuggestions && customerSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-40 overflow-y-auto">
{customerSuggestions.map((c) => (
<button
key={c.id}
onClick={() => selectCustomer(c)}
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-white"
>
<span className="font-medium">{c.name}</span>
{c.city && (
<span className="text-gray-500 dark:text-gray-400 ml-2">({c.city})</span>
)}
</button>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon
</label>
<input
type="text"
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Broj telefona"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Grad
</label>
<input
type="text"
value={customerCity}
onChange={(e) => setCustomerCity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Grad"
/>
</div>
</div>
<div className="mt-3">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beleske o kupcu</div>
<textarea
value={customerNotes}
onChange={(e) => setCustomerNotes(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="npr. stampa figurice, obicno koristi crnu..."
rows={2}
/>
</div>
</div>
{/* Items Section */}
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Stavke</h3>
<button
onClick={addItem}
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
>
Dodaj stavku
</button>
</div>
<div className="space-y-3">
{items.map((item, index) => {
const filament = getFilamentById(item.filament_id);
const available = filament ? getAvailableStock(filament, item.item_type) : 0;
const itemPrice = getItemPrice(item);
return (
<div
key={index}
className="flex flex-wrap gap-3 items-end p-3 bg-gray-50 dark:bg-gray-700/50 rounded border border-gray-200 dark:border-gray-600"
>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Filament
</label>
<select
value={item.filament_id}
onChange={(e) => updateItem(index, { filament_id: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value={0}>-- Izaberite filament --</option>
{filaments
.filter((f) => f.kolicina > 0)
.map((f) => (
<option key={f.id} value={f.id}>
{f.boja} - {f.tip} {f.finish} (refill: {f.refill}, spulna: {f.spulna})
</option>
))}
</select>
</div>
<div className="w-32">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Tip
</label>
<select
value={item.item_type}
onChange={(e) =>
updateItem(index, {
item_type: e.target.value as 'refill' | 'spulna',
quantity: 1,
})
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="refill">Refill</option>
<option value="spulna">Spulna</option>
</select>
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Kolicina {filament ? `(max ${available})` : ''}
</label>
<input
type="number"
min={1}
max={available || undefined}
value={item.quantity}
onChange={(e) => updateItem(index, { quantity: Math.max(1, Number(e.target.value)) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="w-32 text-right">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Cena
</label>
<div className="px-3 py-2 text-sm font-medium text-gray-900 dark:text-white">
{itemPrice > 0 ? formatPrice(itemPrice) : '-'}
</div>
</div>
<div>
<button
onClick={() => removeItem(index)}
disabled={items.length <= 1}
className="px-3 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Ukloni
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Total */}
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">{formatPrice(totalAmount)}</span>
</div>
{/* Notes */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Beleske
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Napomena za prodaju..."
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
>
Otkazi
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{submitting ? 'Cuvanje...' : 'Sacuvaj'}
</button>
</div>
</div>
</div>
</div>
);
}
// --- SaleDetailModal ---
function SaleDetailModal({
sale,
loading,
onClose,
onDelete,
formatPrice,
formatDate,
}: {
sale: Sale | null;
loading: boolean;
onClose: () => void;
onDelete: (id: string) => void;
formatPrice: (n: number) => string;
formatDate: (d?: string) => string;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Detalji prodaje</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
>
&times;
</button>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
) : !sale ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Prodaja nije pronadjena</div>
) : (
<>
{/* Customer Info */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Ime: </span>
<span className="text-gray-900 dark:text-white font-medium">
{sale.customer_name || '-'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Telefon: </span>
<span className="text-gray-900 dark:text-white">
{sale.customer_phone || '-'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Datum: </span>
<span className="text-gray-900 dark:text-white">{formatDate(sale.created_at)}</span>
</div>
</div>
</div>
{/* Items */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Stavke</h3>
{sale.items && sale.items.length > 0 ? (
<div className="border border-gray-200 dark:border-gray-600 rounded overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Filament
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Tip
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Kolicina
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Cena
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-600">
{sale.items.map((item: SaleItem) => (
<tr key={item.id}>
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white">
{item.filament_boja} - {item.filament_tip} {item.filament_finish}
</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 capitalize">
{item.item_type}
</td>
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right">
{item.quantity}
</td>
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right font-medium">
{formatPrice(item.unit_price * item.quantity)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">Nema stavki</p>
)}
</div>
{/* Total */}
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">
{formatPrice(sale.total_amount)}
</span>
</div>
{/* Notes */}
{sale.notes && (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Beleske</h3>
<p className="text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-gray-700/50 p-3 rounded">
{sale.notes}
</p>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<button
onClick={() => {
if (window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) {
onDelete(sale.id);
}
}}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Obrisi prodaju
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
>
Zatvori
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}