813 lines
24 KiB
JavaScript
813 lines
24 KiB
JavaScript
const express = require('express');
|
|
const { Pool } = require('pg');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const puppeteer = require('puppeteer-core');
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Get next quote number (MUST be before /:id route)
|
|
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' });
|
|
}
|
|
});
|
|
|
|
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) => {
|
|
console.log('PUT /api/quotes/:id called with id:', req.params.id);
|
|
console.log('Request body:', JSON.stringify(req.body, null, 2));
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Upload logo
|
|
app.post('/api/upload-logo', upload.single('logo'), (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No file uploaded' });
|
|
}
|
|
|
|
// Save as "current_logo" for easy access
|
|
const logoPath = path.join(__dirname, 'uploads', 'current_logo' + path.extname(req.file.filename));
|
|
fs.renameSync(req.file.path, logoPath);
|
|
|
|
res.json({
|
|
filename: 'current_logo' + path.extname(req.file.filename),
|
|
path: `/uploads/current_logo${path.extname(req.file.filename)}`
|
|
});
|
|
});
|
|
|
|
// Get logo info
|
|
app.get('/api/logo-info', (req, res) => {
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
const possibleExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
|
|
|
|
for (const ext of possibleExtensions) {
|
|
const logoPath = path.join(uploadsDir, 'current_logo' + ext);
|
|
if (fs.existsSync(logoPath)) {
|
|
return res.json({
|
|
hasLogo: true,
|
|
logoPath: `/uploads/current_logo${ext}`
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({ hasLogo: false });
|
|
});
|
|
|
|
// Generate PDF
|
|
app.post('/api/quotes/:id/pdf', async (req, res) => {
|
|
let browser;
|
|
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);
|
|
|
|
console.log('Starting PDF generation for quote', quote.quote_number);
|
|
|
|
// Generate PDF with Puppeteer
|
|
browser = await puppeteer.launch({
|
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser',
|
|
headless: true,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-gpu',
|
|
'--disable-software-rasterizer',
|
|
'--disable-extensions'
|
|
]
|
|
});
|
|
|
|
console.log('Browser launched, creating page...');
|
|
const page = await browser.newPage();
|
|
|
|
await page.setContent(html, {
|
|
waitUntil: 'networkidle0',
|
|
timeout: 30000
|
|
});
|
|
|
|
console.log('Content set, generating PDF...');
|
|
|
|
const pdf = await page.pdf({
|
|
format: 'Letter',
|
|
printBackground: true,
|
|
preferCSSPageSize: false,
|
|
margin: {
|
|
top: '0.4in',
|
|
right: '0.4in',
|
|
bottom: '0.4in',
|
|
left: '0.4in'
|
|
}
|
|
});
|
|
|
|
await browser.close();
|
|
browser = null;
|
|
|
|
console.log('PDF generated successfully, size:', pdf.length, 'bytes');
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Length', pdf.length);
|
|
res.setHeader('Content-Disposition', `attachment; filename="Quote_${quote.quote_number}.pdf"`);
|
|
res.send(pdf);
|
|
} catch (err) {
|
|
console.error('PDF Generation Error:', err);
|
|
if (browser) {
|
|
try {
|
|
await browser.close();
|
|
} catch (closeErr) {
|
|
console.error('Error closing browser:', closeErr);
|
|
}
|
|
}
|
|
res.status(500).json({ error: 'Error generating PDF: ' + err.message });
|
|
}
|
|
});
|
|
|
|
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()}`;
|
|
};
|
|
|
|
// Check for logo file
|
|
let logoHTML = '<div style="width: 40px; height: 40px; background-color: #1e40af; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 18px;">BA</div>';
|
|
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
const possibleExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
|
|
|
|
for (const ext of possibleExtensions) {
|
|
const logoPath = path.join(uploadsDir, 'current_logo' + ext);
|
|
if (fs.existsSync(logoPath)) {
|
|
try {
|
|
const logoBuffer = fs.readFileSync(logoPath);
|
|
const logoBase64 = logoBuffer.toString('base64');
|
|
const mimeType = ext === '.png' ? 'image/png' : 'image/jpeg';
|
|
logoHTML = `<img class="logo-size" src="data:${mimeType};base64,${logoBase64}" alt="Logo">`;
|
|
} catch (err) {
|
|
console.error('Error reading logo file:', err);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
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">
|
|
${logoHTML}
|
|
<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);
|
|
}); |