diff --git a/api/server.js b/api/server.js index b7ff4ec..fbc6786 100644 --- a/api/server.js +++ b/api/server.js @@ -385,6 +385,420 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => { } }); +// ========================================== +// Customer endpoints (admin-only) +// ========================================== + +app.get('/api/customers', authenticateToken, async (req, res) => { + try { + const result = await pool.query('SELECT * FROM customers ORDER BY name'); + res.json(result.rows); + } catch (error) { + console.error('Error fetching customers:', error); + res.status(500).json({ error: 'Failed to fetch customers' }); + } +}); + +app.get('/api/customers/search', authenticateToken, async (req, res) => { + try { + const { q } = req.query; + if (!q) return res.json([]); + const result = await pool.query( + `SELECT * FROM customers WHERE name ILIKE $1 OR phone ILIKE $1 ORDER BY name LIMIT 10`, + [`%${q}%`] + ); + res.json(result.rows); + } catch (error) { + console.error('Error searching customers:', error); + res.status(500).json({ error: 'Failed to search customers' }); + } +}); + +app.get('/api/customers/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const customerResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]); + if (customerResult.rows.length === 0) { + return res.status(404).json({ error: 'Customer not found' }); + } + const salesResult = await pool.query( + `SELECT s.*, + (SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count + FROM sales s WHERE s.customer_id = $1 ORDER BY s.created_at DESC`, + [id] + ); + res.json({ ...customerResult.rows[0], sales: salesResult.rows }); + } catch (error) { + console.error('Error fetching customer:', error); + res.status(500).json({ error: 'Failed to fetch customer' }); + } +}); + +app.post('/api/customers', authenticateToken, async (req, res) => { + const { name, phone, city, notes } = req.body; + if (!name) return res.status(400).json({ error: 'Name is required' }); + try { + const result = await pool.query( + 'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING *', + [name, phone || null, city || null, notes || null] + ); + res.json(result.rows[0]); + } catch (error) { + if (error.code === '23505') { + return res.status(409).json({ error: 'Customer with this phone already exists' }); + } + console.error('Error creating customer:', error); + res.status(500).json({ error: 'Failed to create customer' }); + } +}); + +app.put('/api/customers/:id', authenticateToken, async (req, res) => { + const { id } = req.params; + const { name, phone, city, notes } = req.body; + try { + const result = await pool.query( + `UPDATE customers SET name = $1, phone = $2, city = $3, notes = $4, updated_at = CURRENT_TIMESTAMP + WHERE id = $5 RETURNING *`, + [name, phone || null, city || null, notes || null, id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Customer not found' }); + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating customer:', error); + res.status(500).json({ error: 'Failed to update customer' }); + } +}); + +// ========================================== +// Sale endpoints (admin-only) +// ========================================== + +app.post('/api/sales', authenticateToken, async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { customer, items, notes } = req.body; + + if (!customer || !customer.name) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'Customer name is required' }); + } + if (!items || items.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'At least one item is required' }); + } + + // Find-or-create customer by phone + let customerId; + if (customer.phone) { + const existing = await client.query( + 'SELECT id FROM customers WHERE phone = $1', [customer.phone] + ); + if (existing.rows.length > 0) { + customerId = existing.rows[0].id; + // Update name/city if provided + await client.query( + `UPDATE customers SET name = $1, city = COALESCE($2, city), notes = COALESCE($3, notes), updated_at = CURRENT_TIMESTAMP WHERE id = $4`, + [customer.name, customer.city, customer.notes, customerId] + ); + } + } + if (!customerId) { + const newCust = await client.query( + 'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING id', + [customer.name, customer.phone || null, customer.city || null, customer.notes || null] + ); + customerId = newCust.rows[0].id; + } + + // Process items + let totalAmount = 0; + const saleItems = []; + + for (const item of items) { + const { filament_id, item_type, quantity } = item; + if (!['refill', 'spulna'].includes(item_type)) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: `Invalid item_type: ${item_type}` }); + } + + // Get filament with stock check + const filamentResult = await client.query( + 'SELECT f.*, c.cena_refill, c.cena_spulna FROM filaments f JOIN colors c ON f.boja = c.name WHERE f.id = $1 FOR UPDATE', + [filament_id] + ); + if (filamentResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: `Filament ${filament_id} not found` }); + } + + const filament = filamentResult.rows[0]; + if (filament[item_type] < quantity) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: `Insufficient stock for ${filament.boja} ${item_type}: have ${filament[item_type]}, need ${quantity}` + }); + } + + // Determine price (apply sale discount if active) + let unitPrice = item_type === 'refill' ? (filament.cena_refill || 3499) : (filament.cena_spulna || 3999); + if (filament.sale_active && filament.sale_percentage > 0) { + unitPrice = Math.round(unitPrice * (1 - filament.sale_percentage / 100)); + } + + // Decrement inventory + const updateField = item_type === 'refill' ? 'refill' : 'spulna'; + await client.query( + `UPDATE filaments SET ${updateField} = ${updateField} - $1, kolicina = kolicina - $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, + [quantity, filament_id] + ); + + totalAmount += unitPrice * quantity; + saleItems.push({ filament_id, item_type, quantity, unit_price: unitPrice }); + } + + // Insert sale + const saleResult = await client.query( + 'INSERT INTO sales (customer_id, total_amount, notes) VALUES ($1, $2, $3) RETURNING *', + [customerId, totalAmount, notes || null] + ); + const sale = saleResult.rows[0]; + + // Insert sale items + for (const si of saleItems) { + await client.query( + 'INSERT INTO sale_items (sale_id, filament_id, item_type, quantity, unit_price) VALUES ($1, $2, $3, $4, $5)', + [sale.id, si.filament_id, si.item_type, si.quantity, si.unit_price] + ); + } + + await client.query('COMMIT'); + + // Fetch full sale with items + const fullSale = await pool.query( + `SELECT s.*, c.name as customer_name, c.phone as customer_phone + FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`, + [sale.id] + ); + const fullItems = await pool.query( + `SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja + FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1`, + [sale.id] + ); + res.json({ ...fullSale.rows[0], items: fullItems.rows }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error creating sale:', error); + res.status(500).json({ error: 'Failed to create sale' }); + } finally { + client.release(); + } +}); + +app.get('/api/sales', authenticateToken, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + const offset = (page - 1) * limit; + + const countResult = await pool.query('SELECT COUNT(*) FROM sales'); + const total = parseInt(countResult.rows[0].count); + + const result = await pool.query( + `SELECT s.*, c.name as customer_name, c.phone as customer_phone, + (SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count + FROM sales s LEFT JOIN customers c ON s.customer_id = c.id + ORDER BY s.created_at DESC LIMIT $1 OFFSET $2`, + [limit, offset] + ); + res.json({ sales: result.rows, total }); + } catch (error) { + console.error('Error fetching sales:', error); + res.status(500).json({ error: 'Failed to fetch sales' }); + } +}); + +app.get('/api/sales/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const saleResult = await pool.query( + `SELECT s.*, c.name as customer_name, c.phone as customer_phone, c.city as customer_city + FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`, + [id] + ); + if (saleResult.rows.length === 0) return res.status(404).json({ error: 'Sale not found' }); + + const itemsResult = await pool.query( + `SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja + FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1 ORDER BY si.created_at`, + [id] + ); + res.json({ ...saleResult.rows[0], items: itemsResult.rows }); + } catch (error) { + console.error('Error fetching sale:', error); + res.status(500).json({ error: 'Failed to fetch sale' }); + } +}); + +app.delete('/api/sales/:id', authenticateToken, async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { id } = req.params; + + // Get sale items to restore inventory + const itemsResult = await client.query('SELECT * FROM sale_items WHERE sale_id = $1', [id]); + if (itemsResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Sale not found' }); + } + + // Restore inventory for each item + for (const item of itemsResult.rows) { + const updateField = item.item_type === 'refill' ? 'refill' : 'spulna'; + await client.query( + `UPDATE filaments SET ${updateField} = ${updateField} + $1, kolicina = kolicina + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, + [item.quantity, item.filament_id] + ); + } + + // Delete sale (cascade deletes sale_items) + await client.query('DELETE FROM sales WHERE id = $1', [id]); + await client.query('COMMIT'); + res.json({ success: true }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error deleting sale:', error); + res.status(500).json({ error: 'Failed to delete sale' }); + } finally { + client.release(); + } +}); + +// ========================================== +// Analytics endpoints (admin-only) +// ========================================== + +function getPeriodInterval(period) { + const map = { + '7d': '7 days', '30d': '30 days', '90d': '90 days', + '6m': '6 months', '1y': '1 year', 'all': '100 years' + }; + return map[period] || '30 days'; +} + +app.get('/api/analytics/overview', authenticateToken, async (req, res) => { + try { + const interval = getPeriodInterval(req.query.period); + const result = await pool.query( + `SELECT + COALESCE(SUM(total_amount), 0) as revenue, + COUNT(*) as sales_count, + COALESCE(ROUND(AVG(total_amount)), 0) as avg_order_value, + COUNT(DISTINCT customer_id) as unique_customers + FROM sales WHERE created_at >= NOW() - $1::interval`, + [interval] + ); + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching analytics overview:', error); + res.status(500).json({ error: 'Failed to fetch analytics' }); + } +}); + +app.get('/api/analytics/top-sellers', authenticateToken, async (req, res) => { + try { + const interval = getPeriodInterval(req.query.period); + const result = await pool.query( + `SELECT f.boja, f.tip, f.finish, + SUM(si.quantity) as total_qty, + SUM(si.quantity * si.unit_price) as total_revenue + FROM sale_items si + JOIN filaments f ON si.filament_id = f.id + JOIN sales s ON si.sale_id = s.id + WHERE s.created_at >= NOW() - $1::interval + GROUP BY f.boja, f.tip, f.finish + ORDER BY total_qty DESC LIMIT 10`, + [interval] + ); + res.json(result.rows); + } catch (error) { + console.error('Error fetching top sellers:', error); + res.status(500).json({ error: 'Failed to fetch top sellers' }); + } +}); + +app.get('/api/analytics/inventory-alerts', authenticateToken, async (req, res) => { + try { + const result = await pool.query( + `SELECT f.id, f.boja, f.tip, f.finish, f.refill, f.spulna, f.kolicina, + COALESCE( + (SELECT SUM(si.quantity)::float / GREATEST(EXTRACT(EPOCH FROM (NOW() - MIN(s.created_at))) / 86400, 1) + FROM sale_items si JOIN sales s ON si.sale_id = s.id + WHERE si.filament_id = f.id AND s.created_at >= NOW() - INTERVAL '90 days'), + 0 + ) as avg_daily_sales + FROM filaments f + WHERE f.kolicina <= 5 + ORDER BY f.kolicina ASC, f.boja` + ); + const rows = result.rows.map(r => ({ + ...r, + avg_daily_sales: parseFloat(r.avg_daily_sales) || 0, + days_until_stockout: r.avg_daily_sales > 0 + ? Math.round(r.kolicina / r.avg_daily_sales) + : null + })); + res.json(rows); + } catch (error) { + console.error('Error fetching inventory alerts:', error); + res.status(500).json({ error: 'Failed to fetch inventory alerts' }); + } +}); + +app.get('/api/analytics/revenue-chart', authenticateToken, async (req, res) => { + try { + const interval = getPeriodInterval(req.query.period || '6m'); + const group = req.query.group || 'month'; + const truncTo = group === 'day' ? 'day' : group === 'week' ? 'week' : 'month'; + + const result = await pool.query( + `SELECT DATE_TRUNC($1, created_at) as period, + SUM(total_amount) as revenue, + COUNT(*) as count + FROM sales + WHERE created_at >= NOW() - $2::interval + GROUP BY DATE_TRUNC($1, created_at) + ORDER BY period`, + [truncTo, interval] + ); + res.json(result.rows); + } catch (error) { + console.error('Error fetching revenue chart:', error); + res.status(500).json({ error: 'Failed to fetch revenue chart' }); + } +}); + +app.get('/api/analytics/type-breakdown', authenticateToken, async (req, res) => { + try { + const interval = getPeriodInterval(req.query.period); + const result = await pool.query( + `SELECT si.item_type, + SUM(si.quantity) as total_qty, + SUM(si.quantity * si.unit_price) as total_revenue + FROM sale_items si + JOIN sales s ON si.sale_id = s.id + WHERE s.created_at >= NOW() - $1::interval + GROUP BY si.item_type`, + [interval] + ); + res.json(result.rows); + } catch (error) { + console.error('Error fetching type breakdown:', error); + res.status(500).json({ error: 'Failed to fetch type breakdown' }); + } +}); + app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); \ No newline at end of file diff --git a/app/upadaj/analytics/page.tsx b/app/upadaj/analytics/page.tsx new file mode 100644 index 0000000..0703c18 --- /dev/null +++ b/app/upadaj/analytics/page.tsx @@ -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 = { + '7d': '7d', + '30d': '30d', + '90d': '90d', + '6m': '6m', + '1y': '1y', + 'all': 'Sve', +}; + +const PERIOD_GROUP: Record = { + '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('30d'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [overview, setOverview] = useState(null); + const [topSellers, setTopSellers] = useState([]); + const [revenueData, setRevenueData] = useState([]); + const [inventoryAlerts, setInventoryAlerts] = useState([]); + const [typeBreakdown, setTypeBreakdown] = useState([]); + + // 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 ( +
+
Ucitavanje...
+
+ ); + } + + return ( +
+
+ {/* Sidebar */} +
+
+

Admin Panel

+ +
+
+ + {/* Main Content */} +
+
+
+
+
+ Filamenteka +

Analitika

+
+
+ + {mounted && ( + + )} + +
+
+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Period Selector */} +
+ {(Object.keys(PERIOD_LABELS) as Period[]).map((p) => ( + + ))} +
+ + {loading ? ( +
+
Ucitavanje analitike...
+
+ ) : ( + <> + {/* Overview Cards */} +
+
+

Prihod

+

+ {overview ? formatRSD(overview.revenue) : '-'} +

+
+
+

Broj prodaja

+

+ {overview ? overview.sales_count : '-'} +

+
+
+

Prosecna vrednost

+

+ {overview ? formatRSD(overview.avg_order_value) : '-'} +

+
+
+

Jedinstveni kupci

+

+ {overview ? overview.unique_customers : '-'} +

+
+
+ + {/* Revenue Chart */} +
+

Prihod po periodu

+
+ {mounted && revenueData.length > 0 ? ( + + + + + formatRSD(value)} + tick={{ fontSize: 12 }} + /> + [formatRSD(value), 'Prihod']) as any} + contentStyle={{ + backgroundColor: darkMode ? '#1f2937' : '#ffffff', + border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`, + borderRadius: '0.5rem', + color: darkMode ? '#f3f4f6' : '#111827', + }} + /> + + + + ) : ( +
+ Nema podataka za prikaz +
+ )} +
+
+ + {/* Charts Row */} +
+ {/* Top Sellers */} +
+

Najprodavanije boje

+
+ {mounted && topSellers.length > 0 ? ( + + + + + { + const seller = topSellers[index]; + return seller ? `${value} (${seller.tip})` : value; + }} + /> + [value, 'Kolicina']) as any} + contentStyle={{ + backgroundColor: darkMode ? '#1f2937' : '#ffffff', + border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`, + borderRadius: '0.5rem', + color: darkMode ? '#f3f4f6' : '#111827', + }} + /> + + + + ) : ( +
+ Nema podataka za prikaz +
+ )} +
+
+ + {/* Type Breakdown */} +
+

Refill vs Spulna

+
+ {mounted && typeBreakdown.length > 0 ? ( + + + `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`} + > + {typeBreakdown.map((_, index) => ( + + ))} + + [ + `${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', + }} + /> + + + + ) : ( +
+ Nema podataka za prikaz +
+ )} +
+
+
+ + {/* Inventory Alerts */} +
+

Upozorenja za zalihe

+ {inventoryAlerts.length > 0 ? ( +
+ + + + + + + + + + + + + + + {inventoryAlerts.map((alert) => ( + + + + + + + + + + + ))} + +
BojaTipFinishRefillSpulnaKolicinaPros. dnevna prodajaDana do nestanka
{alert.boja}{alert.tip}{alert.finish}{alert.refill}{alert.spulna}{alert.kolicina} + {alert.avg_daily_sales > 0 ? alert.avg_daily_sales.toFixed(1) : 'N/A'} + + {alert.days_until_stockout !== null ? ( + + {Math.round(alert.days_until_stockout)} + + ) : ( + N/A + )} +
+
+ ) : ( +

Sve zalihe su na zadovoljavajucem nivou.

+ )} +
+ + )} +
+
+
+
+ ); +} diff --git a/app/upadaj/colors/page.tsx b/app/upadaj/colors/page.tsx index bce9847..28c9bad 100644 --- a/app/upadaj/colors/page.tsx +++ b/app/upadaj/colors/page.tsx @@ -193,18 +193,12 @@ export default function ColorsManagement() { diff --git a/app/upadaj/customers/page.tsx b/app/upadaj/customers/page.tsx new file mode 100644 index 0000000..22533b8 --- /dev/null +++ b/app/upadaj/customers/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [expandedCustomerId, setExpandedCustomerId] = useState(null); + const [expandedSales, setExpandedSales] = useState([]); + const [loadingSales, setLoadingSales] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + const [editForm, setEditForm] = useState>({}); + 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 ( +
+
Ucitavanje...
+
+ ); + } + + return ( +
+
+ {/* Sidebar */} + + + {/* Main Content */} +
+
+
+
+
+ Filamenteka +

+ Upravljanje kupcima +

+
+
+ + {mounted && ( + + )} + +
+
+
+
+ +
+ {error && ( +
+ {error} + +
+ )} + + {/* Search bar */} +
+ 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" + /> +
+ + {/* Customer count */} +

+ Ukupno kupaca: {filteredCustomers.length} +

+ + {/* Customer table */} +
+ + + + + + + + + + + + + {filteredCustomers.length === 0 ? ( + + + + ) : ( + filteredCustomers.map((customer) => ( + <> + + {editingCustomer?.id === customer.id ? ( + <> + + + +
+ Ime + + Telefon + + Grad + + Beleske + + Datum registracije + + Akcije +
+ {searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'} +
+ + 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" + /> + + + 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" + /> + + + 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" + /> + +