Add sales tracking system with customers, analytics, and inventory management
All checks were successful
Deploy / deploy (push) Successful in 2m26s
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:
414
api/server.js
414
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}`);
|
||||
});
|
||||
439
app/upadaj/analytics/page.tsx
Normal file
439
app/upadaj/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
535
app/upadaj/customers/page.tsx
Normal file
535
app/upadaj/customers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
869
app/upadaj/sales/page.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
16
database/migrations/021_add_customers_table.sql
Normal file
16
database/migrations/021_add_customers_table.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Add customers table for tracking buyers
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
city VARCHAR(255),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Phone is the natural dedup key
|
||||
CREATE UNIQUE INDEX idx_customers_phone ON customers (phone) WHERE phone IS NOT NULL;
|
||||
CREATE INDEX idx_customers_name ON customers (name);
|
||||
24
database/migrations/022_add_sales_tables.sql
Normal file
24
database/migrations/022_add_sales_tables.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Add sales and sale_items tables for tracking transactions
|
||||
CREATE TABLE sales (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||
total_amount INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE sale_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sale_id UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||
filament_id UUID NOT NULL REFERENCES filaments(id) ON DELETE RESTRICT,
|
||||
item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('refill', 'spulna')),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sales_customer_id ON sales (customer_id);
|
||||
CREATE INDEX idx_sales_created_at ON sales (created_at);
|
||||
CREATE INDEX idx_sale_items_sale_id ON sale_items (sale_id);
|
||||
CREATE INDEX idx_sale_items_filament_id ON sale_items (filament_id);
|
||||
407
package-lock.json
generated
407
package-lock.json
generated
@@ -15,7 +15,8 @@
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -2672,6 +2673,42 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3056,6 +3093,18 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -3214,6 +3263,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3326,7 +3438,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3363,6 +3475,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -4560,6 +4678,15 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -4715,9 +4842,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
@@ -4757,6 +5005,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
||||
@@ -5059,6 +5313,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -5336,6 +5600,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
@@ -6012,6 +6282,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6088,6 +6368,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -8569,10 +8858,32 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -8606,6 +8917,36 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -8620,6 +8961,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -8630,6 +8986,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -9423,6 +9785,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -9724,6 +10092,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -9746,6 +10123,28 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { Customer, Sale, CreateSaleRequest, AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
@@ -132,4 +133,79 @@ export const colorRequestService = {
|
||||
},
|
||||
};
|
||||
|
||||
export const customerService = {
|
||||
getAll: async (): Promise<Customer[]> => {
|
||||
const response = await api.get('/customers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
search: async (q: string): Promise<Customer[]> => {
|
||||
const response = await api.get(`/customers/search?q=${encodeURIComponent(q)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Customer & { sales: Sale[] }> => {
|
||||
const response = await api.get(`/customers/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (customer: Partial<Customer>): Promise<Customer> => {
|
||||
const response = await api.post('/customers', customer);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, customer: Partial<Customer>): Promise<Customer> => {
|
||||
const response = await api.put(`/customers/${id}`, customer);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const saleService = {
|
||||
getAll: async (page = 1, limit = 50): Promise<{ sales: Sale[]; total: number }> => {
|
||||
const response = await api.get(`/sales?page=${page}&limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Sale> => {
|
||||
const response = await api.get(`/sales/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateSaleRequest): Promise<Sale> => {
|
||||
const response = await api.post('/sales', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/sales/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const analyticsService = {
|
||||
getOverview: async (period = '30d'): Promise<AnalyticsOverview> => {
|
||||
const response = await api.get(`/analytics/overview?period=${period}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTopSellers: async (period = '30d'): Promise<TopSeller[]> => {
|
||||
const response = await api.get(`/analytics/top-sellers?period=${period}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getInventoryAlerts: async (): Promise<InventoryAlert[]> => {
|
||||
const response = await api.get('/analytics/inventory-alerts');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRevenueChart: async (period = '6m', group = 'month'): Promise<RevenueDataPoint[]> => {
|
||||
const response = await api.get(`/analytics/revenue-chart?period=${period}&group=${group}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTypeBreakdown: async (period = '30d'): Promise<TypeBreakdown[]> => {
|
||||
const response = await api.get(`/analytics/type-breakdown?period=${period}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
91
src/types/sales.ts
Normal file
91
src/types/sales.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
notes?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface SaleItem {
|
||||
id: string;
|
||||
sale_id: string;
|
||||
filament_id: string;
|
||||
item_type: 'refill' | 'spulna';
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
created_at?: string;
|
||||
// Joined fields
|
||||
filament_tip?: string;
|
||||
filament_finish?: string;
|
||||
filament_boja?: string;
|
||||
}
|
||||
|
||||
export interface Sale {
|
||||
id: string;
|
||||
customer_id?: string;
|
||||
total_amount: number;
|
||||
notes?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// Joined fields
|
||||
customer_name?: string;
|
||||
customer_phone?: string;
|
||||
items?: SaleItem[];
|
||||
item_count?: number;
|
||||
}
|
||||
|
||||
export interface CreateSaleRequest {
|
||||
customer: {
|
||||
name: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
filament_id: string;
|
||||
item_type: 'refill' | 'spulna';
|
||||
quantity: number;
|
||||
}[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
revenue: number;
|
||||
sales_count: number;
|
||||
avg_order_value: number;
|
||||
unique_customers: number;
|
||||
}
|
||||
|
||||
export interface TopSeller {
|
||||
boja: string;
|
||||
tip: string;
|
||||
finish: string;
|
||||
total_qty: number;
|
||||
total_revenue: number;
|
||||
}
|
||||
|
||||
export interface RevenueDataPoint {
|
||||
period: string;
|
||||
revenue: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InventoryAlert {
|
||||
id: string;
|
||||
boja: string;
|
||||
tip: string;
|
||||
finish: string;
|
||||
refill: number;
|
||||
spulna: number;
|
||||
kolicina: number;
|
||||
avg_daily_sales: number;
|
||||
days_until_stockout: number | null;
|
||||
}
|
||||
|
||||
export interface TypeBreakdown {
|
||||
item_type: string;
|
||||
total_qty: number;
|
||||
total_revenue: number;
|
||||
}
|
||||
Reference in New Issue
Block a user