refactoring 1. step
This commit is contained in:
370
src/routes/quotes.js
Normal file
370
src/routes/quotes.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Quote Routes
|
||||
* Handles quote CRUD operations and PDF generation
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { pool } = require('../config/database');
|
||||
const { getNextQuoteNumber } = require('../utils/numberGenerators');
|
||||
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderQuoteItems, formatAddressLines } = require('../services/pdf-service');
|
||||
|
||||
// GET all quotes
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
ORDER BY q.created_at DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching quotes:', error);
|
||||
res.status(500).json({ error: 'Error fetching quotes' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET single quote
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
quote: quoteResult.rows[0],
|
||||
items: itemsResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching quote:', error);
|
||||
res.status(500).json({ error: 'Error fetching quote' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create quote
|
||||
router.post('/', async (req, res) => {
|
||||
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const quote_number = await getNextQuoteNumber();
|
||||
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||
if (!isNaN(amount)) {
|
||||
subtotal += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tax_rate = 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
const quoteResult = await client.query(
|
||||
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd]
|
||||
);
|
||||
|
||||
const quoteId = quoteResult.rows[0].id;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await client.query(
|
||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error creating quote:', error);
|
||||
res.status(500).json({ error: 'Error creating quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT update quote
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||
if (!isNaN(amount)) {
|
||||
subtotal += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tax_rate = 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
await client.query(
|
||||
`UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4,
|
||||
subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9`,
|
||||
[customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id]
|
||||
);
|
||||
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await client.query(
|
||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error updating quote:', error);
|
||||
res.status(500).json({ error: 'Error updating quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE quote
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||
await client.query('DELETE FROM quotes WHERE id = $1', [id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error deleting quote:', error);
|
||||
res.status(500).json({ error: 'Error deleting quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// GET quote PDF
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
||||
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||
|
||||
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||
|
||||
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||
.replace('{{ITEMS}}', itemsHTML)
|
||||
.replace('{{TBD_NOTE}}', tbdNote);
|
||||
|
||||
const pdf = await generatePdfFromHtml(html);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': pdf.length,
|
||||
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
|
||||
});
|
||||
res.end(pdf, 'binary');
|
||||
console.log('[PDF] Quote PDF sent successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PDF] ERROR:', error);
|
||||
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET quote HTML (debug)
|
||||
router.get('/:id/html', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||
|
||||
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||
|
||||
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||
.replace('{{ITEMS}}', itemsHTML)
|
||||
.replace('{{TBD_NOTE}}', tbdNote);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[HTML] ERROR:', error);
|
||||
res.status(500).json({ error: 'Error generating HTML' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST convert quote to invoice
|
||||
router.post('/:id/convert-to-invoice', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const hasTBD = itemsResult.rows.some(item =>
|
||||
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
|
||||
);
|
||||
|
||||
if (hasTBD) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
|
||||
}
|
||||
|
||||
const invoice_number = null;
|
||||
const invoiceDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
const invoiceResult = await client.query(
|
||||
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id]
|
||||
);
|
||||
|
||||
const invoiceId = invoiceResult.rows[0].id;
|
||||
|
||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||
const item = itemsResult.rows[i];
|
||||
await client.query(
|
||||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(invoiceResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error converting quote to invoice:', error);
|
||||
res.status(500).json({ error: 'Error converting quote to invoice' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user