init
This commit is contained in:
734
server.js
Normal file
734
server.js
Normal file
@@ -0,0 +1,734 @@
|
||||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Database configuration
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'quoteuser',
|
||||
password: process.env.DB_PASSWORD || 'quotepass123',
|
||||
database: process.env.DB_NAME || 'quotedb'
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// Configure multer for logo upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = './uploads';
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, 'logo_' + Date.now() + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
const filetypes = /jpeg|jpg|png|gif/;
|
||||
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = filetypes.test(file.mimetype);
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
});
|
||||
|
||||
// Generate next quote number
|
||||
async function generateQuoteNumber() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const prefix = `${year}-${month}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT quote_number FROM quotes
|
||||
WHERE quote_number LIKE $1
|
||||
ORDER BY quote_number DESC
|
||||
LIMIT 1`,
|
||||
[prefix + '%']
|
||||
);
|
||||
|
||||
let nextNumber = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return prefix + String(nextNumber).padStart(4, '0');
|
||||
}
|
||||
|
||||
// API Routes
|
||||
|
||||
// Customers
|
||||
app.get('/api/customers', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM customers ORDER BY name'
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/customers/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM customers WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/customers', async (req, res) => {
|
||||
const { name, street, city, state, zip_code, account_number } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO customers (name, street, city, state, zip_code, account_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, street, city, state, zip_code, account_number]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/customers/:id', async (req, res) => {
|
||||
const { name, street, city, state, zip_code, account_number } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE customers
|
||||
SET name = $1, street = $2, city = $3, state = $4,
|
||||
zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7
|
||||
RETURNING *`,
|
||||
[name, street, city, state, zip_code, account_number, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/customers/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM customers WHERE id = $1 RETURNING id',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
res.json({ message: 'Customer deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Quotes
|
||||
app.get('/api/quotes', 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.quote_number DESC`
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/quotes/:id', async (req, res) => {
|
||||
try {
|
||||
const quoteResult = await pool.query(
|
||||
`SELECT q.*, c.*
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1`,
|
||||
[req.params.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`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
quote.items = itemsResult.rows;
|
||||
|
||||
res.json(quote);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/quotes', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { customer_id, quote_date, tax_exempt, items, tbd_note } = req.body;
|
||||
|
||||
// Generate quote number
|
||||
const quote_number = await generateQuoteNumber();
|
||||
|
||||
// Calculate totals
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.amount === 'TBD' || item.is_tbd) {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const tax_rate = tax_exempt ? 0 : 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
// Insert quote
|
||||
const quoteResult = await client.query(
|
||||
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt,
|
||||
tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[quote_number, customer_id, quote_date, tax_exempt, tax_rate,
|
||||
subtotal, tax_amount, total, has_tbd, tbd_note]
|
||||
);
|
||||
|
||||
const quote_id = quoteResult.rows[0].id;
|
||||
|
||||
// Insert items
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
await client.query(
|
||||
`INSERT INTO quote_items (quote_id, quantity, description, rate, amount, is_tbd, item_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[quote_id, item.quantity, item.description, item.rate,
|
||||
item.amount, item.is_tbd || false, i]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/quotes/:id', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { customer_id, quote_date, tax_exempt, items, tbd_note } = req.body;
|
||||
|
||||
// Calculate totals
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.amount === 'TBD' || item.is_tbd) {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const tax_rate = tax_exempt ? 0 : 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
// Update quote
|
||||
const quoteResult = 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, tbd_note = $9, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $10
|
||||
RETURNING *`,
|
||||
[customer_id, quote_date, tax_exempt, tax_rate,
|
||||
subtotal, tax_amount, total, has_tbd, tbd_note, req.params.id]
|
||||
);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
// Delete old items
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [req.params.id]);
|
||||
|
||||
// Insert new items
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
await client.query(
|
||||
`INSERT INTO quote_items (quote_id, quantity, description, rate, amount, is_tbd, item_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[req.params.id, item.quantity, item.description, item.rate,
|
||||
item.amount, item.is_tbd || false, i]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/quotes/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM quotes WHERE id = $1 RETURNING id',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json({ message: 'Quote deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get next quote number
|
||||
app.get('/api/quotes/next-number', async (req, res) => {
|
||||
try {
|
||||
const quoteNumber = await generateQuoteNumber();
|
||||
res.json({ quote_number: quoteNumber });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error generating quote number' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload logo
|
||||
app.post('/api/upload-logo', upload.single('logo'), (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
res.json({
|
||||
filename: req.file.filename,
|
||||
path: `/uploads/${req.file.filename}`
|
||||
});
|
||||
});
|
||||
|
||||
// Generate PDF
|
||||
app.post('/api/quotes/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const quoteResult = await pool.query(
|
||||
`SELECT q.*, c.name as customer_name, c.street, 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`,
|
||||
[req.params.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`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
quote.items = itemsResult.rows;
|
||||
|
||||
// Generate HTML for PDF
|
||||
const html = generateQuoteHTML(quote);
|
||||
|
||||
// Generate PDF with Puppeteer
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: 'Letter',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
left: '0'
|
||||
}
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
res.contentType('application/pdf');
|
||||
res.send(pdf);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error generating PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
function generateQuoteHTML(quote) {
|
||||
const formatCurrency = (amount) => {
|
||||
if (amount === 'TBD') return 'TBD';
|
||||
return parseFloat(amount).toFixed(2);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
let itemsHTML = '';
|
||||
quote.items.forEach(item => {
|
||||
itemsHTML += `
|
||||
<tr>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="description">${item.description}</td>
|
||||
<td class="rate">${item.rate}</td>
|
||||
<td class="amount">${item.amount}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
// Add sales tax row if not tax exempt
|
||||
if (!quote.tax_exempt) {
|
||||
itemsHTML += `
|
||||
<tr>
|
||||
<td class="qty"></td>
|
||||
<td class="description">Sales Tax</td>
|
||||
<td class="rate">${quote.tax_rate}%</td>
|
||||
<td class="amount">${formatCurrency(quote.tax_amount)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Total row
|
||||
const totalDisplay = quote.has_tbd ? `$${formatCurrency(quote.total)}*` : `$${formatCurrency(quote.total)}`;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="2" class="thank-you">This quote is valid for 14 days. We appreciate your business.</td>
|
||||
<td class="total-label">Total</td>
|
||||
<td class="total-amount">${totalDisplay}</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const tbdNote = quote.has_tbd && quote.tbd_note
|
||||
? `<p style="font-size: 12px; margin-top: 10px;">*${quote.tbd_note}</p>`
|
||||
: quote.has_tbd
|
||||
? `<p style="font-size: 12px; margin-top: 10px;">*Total excludes items marked as TBD which will be determined based on actual requirements.</p>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quote - Bay Area Affiliates, Inc.</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Times New Roman', Times, serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 8.5in;
|
||||
height:11in;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 40px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.company-details h1 {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.company-details p {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bill-to-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 30px 0 60px 0;
|
||||
}
|
||||
|
||||
.bill-to {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bill-to-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bill-to-address {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #fff;
|
||||
border: 1px solid #000;
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
background-color: #fff;
|
||||
border: 1px solid #000;
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.items-table td.qty {
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.items-table td.description {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items-table td.rate,
|
||||
.items-table td.amount {
|
||||
text-align: right;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.footer-row td {
|
||||
border: 1px solid #000;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding-right: 20px !important;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.thank-you {
|
||||
font-size: 13px;
|
||||
}
|
||||
.logo-size{
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="company-info">
|
||||
<img class="logo-size" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==">
|
||||
<div class="company-details">
|
||||
<h1>Bay Area Affiliates, Inc.</h1>
|
||||
<p>1001 Blucher Street<br>
|
||||
Corpus Christi, Texas 78401</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tagline">
|
||||
<em>Providing IT Services and Support in South Texas Since 1996</em>
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
Phone:<br>
|
||||
(361) 765-8400<br>
|
||||
(361) 765-8401<br>
|
||||
(361) 232-6578<br>
|
||||
Email:<br>
|
||||
support@bayarea-cc.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bill-to-section">
|
||||
<div class="bill-to">
|
||||
<div class="bill-to-label">Quote For:</div>
|
||||
<div class="bill-to-address">
|
||||
${quote.customer_name}<br>
|
||||
${quote.street}<br>
|
||||
${quote.city}, ${quote.state} ${quote.zip_code}
|
||||
</div>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QUOTE #</th>
|
||||
<th>ACCOUNT NO.</th>
|
||||
<th>DATE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>${quote.quote_number}</td>
|
||||
<td>${quote.account_number || ''}</td>
|
||||
<td>${formatDate(quote.quote_date)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QTY</th>
|
||||
<th>DESCRIPTION</th>
|
||||
<th>RATE</th>
|
||||
<th>AMOUNT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHTML}
|
||||
</tbody>
|
||||
</table>
|
||||
${tbdNote}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Quote System running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user