- Restructure from single filament table to multi-category product catalog (filamenti, stampaci, ploce, mlaznice, delovi, oprema) - Add shared layout components (SiteHeader, SiteFooter, CategoryNav, Breadcrumb) - Add reusable UI primitives (Badge, Button, Card, Modal, PriceDisplay, EmptyState) - Add catalog components (CatalogPage, ProductTable, ProductGrid, FilamentCard, ProductCard) - Add admin dashboard with sidebar navigation and category management - Add product API endpoints and database migrations - Add SEO pages (politika-privatnosti, uslovi-koriscenja, robots.txt, sitemap.xml) - Fix light mode: gradient text contrast, category nav accessibility, surface tokens, card shadows, CTA section theming
767 lines
25 KiB
JavaScript
767 lines
25 KiB
JavaScript
const express = require('express');
|
|
const { Pool } = require('pg');
|
|
const cors = require('cors');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
require('dotenv').config();
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 80;
|
|
|
|
// PostgreSQL connection
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL,
|
|
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
|
});
|
|
|
|
// Middleware
|
|
app.use(cors({
|
|
origin: true, // Allow all origins in development
|
|
credentials: true,
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
exposedHeaders: ['Content-Length', 'Content-Type'],
|
|
maxAge: 86400
|
|
}));
|
|
app.use(express.json());
|
|
|
|
// Handle preflight requests
|
|
app.options('*', cors());
|
|
|
|
// Health check route
|
|
app.get('/', (req, res) => {
|
|
res.json({ status: 'ok', service: 'Filamenteka API' });
|
|
});
|
|
|
|
// JWT middleware
|
|
const authenticateToken = (req, res, next) => {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key', (err, user) => {
|
|
if (err) {
|
|
console.error('JWT verification error:', err);
|
|
return res.status(403).json({ error: 'Invalid token' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
});
|
|
};
|
|
|
|
// Auth endpoints
|
|
app.post('/api/login', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
|
|
// For now, simple hardcoded admin check
|
|
if (username === 'admin' && password === process.env.ADMIN_PASSWORD) {
|
|
const token = jwt.sign({ username }, process.env.JWT_SECRET || 'your-secret-key', { expiresIn: '24h' });
|
|
res.json({ token });
|
|
} else {
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
});
|
|
|
|
// Colors endpoints
|
|
app.get('/api/colors', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM colors ORDER BY name');
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
console.error('Error fetching colors:', error);
|
|
res.status(500).json({ error: 'Failed to fetch colors' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/colors', authenticateToken, async (req, res) => {
|
|
const { name, hex, cena_refill, cena_spulna } = req.body;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
'INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES ($1, $2, $3, $4) RETURNING *',
|
|
[name, hex, cena_refill || 3499, cena_spulna || 3999]
|
|
);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error creating color:', error);
|
|
res.status(500).json({ error: 'Failed to create color' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/colors/:id', authenticateToken, async (req, res) => {
|
|
const { id } = req.params;
|
|
const { name, hex, cena_refill, cena_spulna } = req.body;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
'UPDATE colors SET name = $1, hex = $2, cena_refill = $3, cena_spulna = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5 RETURNING *',
|
|
[name, hex, cena_refill || 3499, cena_spulna || 3999, id]
|
|
);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error updating color:', error);
|
|
res.status(500).json({ error: 'Failed to update color' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/colors/:id', authenticateToken, async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
await pool.query('DELETE FROM colors WHERE id = $1', [id]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting color:', error);
|
|
res.status(500).json({ error: 'Failed to delete color' });
|
|
}
|
|
});
|
|
|
|
// Filaments endpoints (PUBLIC - no auth required)
|
|
app.get('/api/filaments', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM filaments ORDER BY created_at DESC');
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
console.error('Error fetching filaments:', error);
|
|
res.status(500).json({ error: 'Failed to fetch filaments' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/filaments', authenticateToken, async (req, res) => {
|
|
const { tip, finish, boja, boja_hex, refill, spulna, cena } = req.body;
|
|
|
|
try {
|
|
// Ensure refill and spulna are numbers
|
|
const refillNum = parseInt(refill) || 0;
|
|
const spulnaNum = parseInt(spulna) || 0;
|
|
const kolicina = refillNum + spulnaNum;
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
|
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena]
|
|
);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error creating filament:', error);
|
|
res.status(500).json({ error: 'Failed to create filament' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
|
|
const { id } = req.params;
|
|
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
|
|
|
|
try {
|
|
// Ensure refill and spulna are numbers
|
|
const refillNum = parseInt(refill) || 0;
|
|
const spulnaNum = parseInt(spulna) || 0;
|
|
const kolicina = refillNum + spulnaNum;
|
|
|
|
// Check if sale fields are provided in the request
|
|
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
|
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
|
|
|
let result;
|
|
if (hasSaleFields) {
|
|
// Update with sale fields if they are provided
|
|
result = await pool.query(
|
|
`UPDATE filaments
|
|
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
|
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
|
sale_percentage = $9, sale_active = $10,
|
|
sale_start_date = $11, sale_end_date = $12,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $13 RETURNING *`,
|
|
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
|
|
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
|
);
|
|
} else {
|
|
// Update without touching sale fields if they are not provided
|
|
result = await pool.query(
|
|
`UPDATE filaments
|
|
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
|
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $9 RETURNING *`,
|
|
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id]
|
|
);
|
|
}
|
|
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error updating filament:', error);
|
|
res.status(500).json({ error: 'Failed to update filament' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/filaments/:id', authenticateToken, async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
await pool.query('DELETE FROM filaments WHERE id = $1', [id]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting filament:', error);
|
|
res.status(500).json({ error: 'Failed to delete filament' });
|
|
}
|
|
});
|
|
|
|
// Bulk sale update endpoint
|
|
app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => {
|
|
const { filamentIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
|
|
|
|
try {
|
|
let query;
|
|
let params;
|
|
|
|
if (filamentIds && filamentIds.length > 0) {
|
|
// Update specific filaments
|
|
query = `
|
|
UPDATE filaments
|
|
SET sale_percentage = $1,
|
|
sale_active = $2,
|
|
sale_start_date = $3,
|
|
sale_end_date = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ANY($5)
|
|
RETURNING *`;
|
|
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, filamentIds];
|
|
} else {
|
|
// Update all filaments
|
|
query = `
|
|
UPDATE filaments
|
|
SET sale_percentage = $1,
|
|
sale_active = $2,
|
|
sale_start_date = $3,
|
|
sale_end_date = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING *`;
|
|
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
res.json({
|
|
success: true,
|
|
updatedCount: result.rowCount,
|
|
updatedFilaments: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating sale:', error);
|
|
res.status(500).json({ error: 'Failed to update sale' });
|
|
}
|
|
});
|
|
|
|
// Color request endpoints
|
|
|
|
// Get all color requests (admin only)
|
|
app.get('/api/color-requests', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
'SELECT * FROM color_requests ORDER BY created_at DESC'
|
|
);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
console.error('Error fetching color requests:', error);
|
|
res.status(500).json({ error: 'Failed to fetch color requests' });
|
|
}
|
|
});
|
|
|
|
// Submit a new color request (public)
|
|
app.post('/api/color-requests', async (req, res) => {
|
|
try {
|
|
const {
|
|
color_name,
|
|
material_type,
|
|
finish_type,
|
|
user_email,
|
|
user_phone,
|
|
user_name,
|
|
description,
|
|
reference_url
|
|
} = req.body;
|
|
|
|
// Validate required fields
|
|
if (!color_name || !material_type || !user_email || !user_phone) {
|
|
return res.status(400).json({
|
|
error: 'Color name, material type, email, and phone are required'
|
|
});
|
|
}
|
|
|
|
// Check if similar request already exists
|
|
const existingRequest = await pool.query(
|
|
`SELECT id, request_count FROM color_requests
|
|
WHERE LOWER(color_name) = LOWER($1)
|
|
AND material_type = $2
|
|
AND (finish_type = $3 OR (finish_type IS NULL AND $3 IS NULL))
|
|
AND status = 'pending'`,
|
|
[color_name, material_type, finish_type]
|
|
);
|
|
|
|
if (existingRequest.rows.length > 0) {
|
|
// Increment request count for existing request
|
|
const result = await pool.query(
|
|
`UPDATE color_requests
|
|
SET request_count = request_count + 1,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING *`,
|
|
[existingRequest.rows[0].id]
|
|
);
|
|
res.json({
|
|
message: 'Your request has been added to an existing request for this color',
|
|
request: result.rows[0]
|
|
});
|
|
} else {
|
|
// Create new request
|
|
const result = await pool.query(
|
|
`INSERT INTO color_requests
|
|
(color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *`,
|
|
[color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url]
|
|
);
|
|
res.json({
|
|
message: 'Color request submitted successfully',
|
|
request: result.rows[0]
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating color request:', error);
|
|
res.status(500).json({ error: 'Failed to submit color request' });
|
|
}
|
|
});
|
|
|
|
// Update color request status (admin only)
|
|
app.put('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { status, admin_notes } = req.body;
|
|
|
|
const result = await pool.query(
|
|
`UPDATE color_requests
|
|
SET status = $1,
|
|
admin_notes = $2,
|
|
processed_at = CURRENT_TIMESTAMP,
|
|
processed_by = $3,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $4
|
|
RETURNING *`,
|
|
[status, admin_notes, req.user.username, id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Color request not found' });
|
|
}
|
|
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error updating color request:', error);
|
|
res.status(500).json({ error: 'Failed to update color request' });
|
|
}
|
|
});
|
|
|
|
// Delete color request (admin only)
|
|
app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await pool.query(
|
|
'DELETE FROM color_requests WHERE id = $1 RETURNING *',
|
|
[id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Color request not found' });
|
|
}
|
|
|
|
res.json({ message: 'Color request deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Error deleting color request:', error);
|
|
res.status(500).json({ error: 'Failed to delete color request' });
|
|
}
|
|
});
|
|
|
|
// Slug generation helper
|
|
const generateSlug = (name) => {
|
|
const base = name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
const suffix = Math.random().toString(36).substring(2, 8);
|
|
return `${base}-${suffix}`;
|
|
};
|
|
|
|
// Products endpoints
|
|
|
|
// List/filter products (PUBLIC)
|
|
app.get('/api/products', async (req, res) => {
|
|
try {
|
|
const { category, condition, printer_model, in_stock, search } = req.query;
|
|
const conditions = [];
|
|
const params = [];
|
|
let paramIndex = 1;
|
|
|
|
if (category) {
|
|
conditions.push(`p.category = $${paramIndex++}`);
|
|
params.push(category);
|
|
}
|
|
if (condition) {
|
|
conditions.push(`p.condition = $${paramIndex++}`);
|
|
params.push(condition);
|
|
}
|
|
if (printer_model) {
|
|
conditions.push(`EXISTS (
|
|
SELECT 1 FROM product_printer_compatibility ppc
|
|
JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
|
WHERE ppc.product_id = p.id AND pm.name = $${paramIndex++}
|
|
)`);
|
|
params.push(printer_model);
|
|
}
|
|
if (in_stock === 'true') {
|
|
conditions.push(`p.quantity > 0`);
|
|
} else if (in_stock === 'false') {
|
|
conditions.push(`p.quantity = 0`);
|
|
}
|
|
if (search) {
|
|
conditions.push(`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`);
|
|
params.push(`%${search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const result = await pool.query(
|
|
`SELECT p.*,
|
|
COALESCE(
|
|
json_agg(
|
|
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
|
|
) FILTER (WHERE pm.id IS NOT NULL),
|
|
'[]'
|
|
) AS compatible_printers
|
|
FROM products p
|
|
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
|
|
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
|
${whereClause}
|
|
GROUP BY p.id
|
|
ORDER BY p.created_at DESC`,
|
|
params
|
|
);
|
|
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
console.error('Error fetching products:', error);
|
|
res.status(500).json({ error: 'Failed to fetch products' });
|
|
}
|
|
});
|
|
|
|
// Get single product (PUBLIC)
|
|
app.get('/api/products/:id', async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT p.*,
|
|
COALESCE(
|
|
json_agg(
|
|
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
|
|
) FILTER (WHERE pm.id IS NOT NULL),
|
|
'[]'
|
|
) AS compatible_printers
|
|
FROM products p
|
|
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
|
|
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
|
WHERE p.id = $1
|
|
GROUP BY p.id`,
|
|
[id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Product not found' });
|
|
}
|
|
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error fetching product:', error);
|
|
res.status(500).json({ error: 'Failed to fetch product' });
|
|
}
|
|
});
|
|
|
|
// Create product (auth required)
|
|
app.post('/api/products', authenticateToken, async (req, res) => {
|
|
const { name, description, category, condition, price, quantity, image_url, printer_model_ids } = req.body;
|
|
|
|
try {
|
|
const slug = generateSlug(name);
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO products (name, slug, description, category, condition, price, quantity, image_url)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
|
[name, slug, description, category, condition || 'new', price, parseInt(quantity) || 0, image_url]
|
|
);
|
|
|
|
const product = result.rows[0];
|
|
|
|
// Insert printer compatibility entries if provided
|
|
if (printer_model_ids && printer_model_ids.length > 0) {
|
|
const compatValues = printer_model_ids
|
|
.map((_, i) => `($1, $${i + 2})`)
|
|
.join(', ');
|
|
await pool.query(
|
|
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
|
|
[product.id, ...printer_model_ids]
|
|
);
|
|
}
|
|
|
|
res.json(product);
|
|
} catch (error) {
|
|
console.error('Error creating product:', error);
|
|
res.status(500).json({ error: 'Failed to create product' });
|
|
}
|
|
});
|
|
|
|
// Update product (auth required)
|
|
app.put('/api/products/:id', authenticateToken, async (req, res) => {
|
|
const { id } = req.params;
|
|
const { name, description, category, condition, price, quantity, image_url, sale_percentage, sale_active, sale_start_date, sale_end_date, printer_model_ids } = req.body;
|
|
|
|
try {
|
|
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
|
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
|
|
|
let result;
|
|
if (hasSaleFields) {
|
|
result = await pool.query(
|
|
`UPDATE products
|
|
SET name = $1, description = $2, category = $3, condition = $4,
|
|
price = $5, quantity = $6, image_url = $7,
|
|
sale_percentage = $8, sale_active = $9,
|
|
sale_start_date = $10, sale_end_date = $11,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $12 RETURNING *`,
|
|
[name, description, category, condition, price, parseInt(quantity) || 0, image_url,
|
|
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
|
);
|
|
} else {
|
|
result = await pool.query(
|
|
`UPDATE products
|
|
SET name = $1, description = $2, category = $3, condition = $4,
|
|
price = $5, quantity = $6, image_url = $7,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $8 RETURNING *`,
|
|
[name, description, category, condition, price, parseInt(quantity) || 0, image_url, id]
|
|
);
|
|
}
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Product not found' });
|
|
}
|
|
|
|
// Update printer compatibility if provided
|
|
if (printer_model_ids !== undefined) {
|
|
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
|
|
|
|
if (printer_model_ids && printer_model_ids.length > 0) {
|
|
const compatValues = printer_model_ids
|
|
.map((_, i) => `($1, $${i + 2})`)
|
|
.join(', ');
|
|
await pool.query(
|
|
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
|
|
[id, ...printer_model_ids]
|
|
);
|
|
}
|
|
}
|
|
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error updating product:', error);
|
|
res.status(500).json({ error: 'Failed to update product' });
|
|
}
|
|
});
|
|
|
|
// Delete product (auth required)
|
|
app.delete('/api/products/:id', authenticateToken, async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
|
|
const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING *', [id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Product not found' });
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting product:', error);
|
|
res.status(500).json({ error: 'Failed to delete product' });
|
|
}
|
|
});
|
|
|
|
// Bulk sale update for products (auth required)
|
|
app.post('/api/products/sale/bulk', authenticateToken, async (req, res) => {
|
|
const { productIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
|
|
|
|
try {
|
|
let query;
|
|
let params;
|
|
|
|
if (productIds && productIds.length > 0) {
|
|
query = `
|
|
UPDATE products
|
|
SET sale_percentage = $1,
|
|
sale_active = $2,
|
|
sale_start_date = $3,
|
|
sale_end_date = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ANY($5)
|
|
RETURNING *`;
|
|
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, productIds];
|
|
} else {
|
|
query = `
|
|
UPDATE products
|
|
SET sale_percentage = $1,
|
|
sale_active = $2,
|
|
sale_start_date = $3,
|
|
sale_end_date = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING *`;
|
|
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
res.json({
|
|
success: true,
|
|
updatedCount: result.rowCount,
|
|
updatedProducts: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating product sale:', error);
|
|
res.status(500).json({ error: 'Failed to update product sale' });
|
|
}
|
|
});
|
|
|
|
// Printer models endpoints
|
|
|
|
// List all printer models (PUBLIC)
|
|
app.get('/api/printer-models', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name');
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
console.error('Error fetching printer models:', error);
|
|
res.status(500).json({ error: 'Failed to fetch printer models' });
|
|
}
|
|
});
|
|
|
|
// Analytics endpoints
|
|
|
|
// Aggregated inventory stats (auth required)
|
|
app.get('/api/analytics/inventory', authenticateToken, async (req, res) => {
|
|
try {
|
|
const filamentStats = await pool.query(`
|
|
SELECT
|
|
COUNT(*) AS total_skus,
|
|
COALESCE(SUM(kolicina), 0) AS total_units,
|
|
COALESCE(SUM(refill), 0) AS total_refills,
|
|
COALESCE(SUM(spulna), 0) AS total_spools,
|
|
COUNT(*) FILTER (WHERE kolicina = 0) AS out_of_stock_skus,
|
|
COALESCE(SUM(kolicina * cena), 0) AS total_inventory_value
|
|
FROM filaments
|
|
`);
|
|
|
|
const productStats = await pool.query(`
|
|
SELECT
|
|
COUNT(*) AS total_skus,
|
|
COALESCE(SUM(quantity), 0) AS total_units,
|
|
COUNT(*) FILTER (WHERE quantity = 0) AS out_of_stock_skus,
|
|
COALESCE(SUM(quantity * price), 0) AS total_inventory_value,
|
|
COUNT(*) FILTER (WHERE category = 'printer') AS printers,
|
|
COUNT(*) FILTER (WHERE category = 'build_plate') AS build_plates,
|
|
COUNT(*) FILTER (WHERE category = 'nozzle') AS nozzles,
|
|
COUNT(*) FILTER (WHERE category = 'spare_part') AS spare_parts,
|
|
COUNT(*) FILTER (WHERE category = 'accessory') AS accessories
|
|
FROM products
|
|
`);
|
|
|
|
const filament = filamentStats.rows[0];
|
|
const product = productStats.rows[0];
|
|
|
|
res.json({
|
|
filaments: {
|
|
total_skus: parseInt(filament.total_skus),
|
|
total_units: parseInt(filament.total_units),
|
|
total_refills: parseInt(filament.total_refills),
|
|
total_spools: parseInt(filament.total_spools),
|
|
out_of_stock_skus: parseInt(filament.out_of_stock_skus),
|
|
total_inventory_value: parseInt(filament.total_inventory_value)
|
|
},
|
|
products: {
|
|
total_skus: parseInt(product.total_skus),
|
|
total_units: parseInt(product.total_units),
|
|
out_of_stock_skus: parseInt(product.out_of_stock_skus),
|
|
total_inventory_value: parseInt(product.total_inventory_value),
|
|
by_category: {
|
|
printers: parseInt(product.printers),
|
|
build_plates: parseInt(product.build_plates),
|
|
nozzles: parseInt(product.nozzles),
|
|
spare_parts: parseInt(product.spare_parts),
|
|
accessories: parseInt(product.accessories)
|
|
}
|
|
},
|
|
combined: {
|
|
total_skus: parseInt(filament.total_skus) + parseInt(product.total_skus),
|
|
total_inventory_value: parseInt(filament.total_inventory_value) + parseInt(product.total_inventory_value),
|
|
out_of_stock_skus: parseInt(filament.out_of_stock_skus) + parseInt(product.out_of_stock_skus)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching inventory analytics:', error);
|
|
res.status(500).json({ error: 'Failed to fetch inventory analytics' });
|
|
}
|
|
});
|
|
|
|
// Active sales overview (auth required)
|
|
app.get('/api/analytics/sales', authenticateToken, async (req, res) => {
|
|
try {
|
|
const filamentSales = await pool.query(`
|
|
SELECT id, tip, finish, boja, cena, sale_percentage, sale_active, sale_start_date, sale_end_date,
|
|
ROUND(cena * (1 - sale_percentage / 100.0)) AS sale_price
|
|
FROM filaments
|
|
WHERE sale_active = true
|
|
ORDER BY sale_percentage DESC
|
|
`);
|
|
|
|
const productSales = await pool.query(`
|
|
SELECT id, name, category, price, sale_percentage, sale_active, sale_start_date, sale_end_date,
|
|
ROUND(price * (1 - sale_percentage / 100.0)) AS sale_price
|
|
FROM products
|
|
WHERE sale_active = true
|
|
ORDER BY sale_percentage DESC
|
|
`);
|
|
|
|
res.json({
|
|
filaments: {
|
|
count: filamentSales.rowCount,
|
|
items: filamentSales.rows
|
|
},
|
|
products: {
|
|
count: productSales.rowCount,
|
|
items: productSales.rows
|
|
},
|
|
total_active_sales: filamentSales.rowCount + productSales.rowCount
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching sales analytics:', error);
|
|
res.status(500).json({ error: 'Failed to fetch sales analytics' });
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running on port ${PORT}`);
|
|
}); |