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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user