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 += ` ${item.quantity} ${item.description} ${item.rate} ${item.amount} `; }); // Add sales tax row if not tax exempt if (!quote.tax_exempt) { itemsHTML += ` Sales Tax ${quote.tax_rate}% ${formatCurrency(quote.tax_amount)} `; } // Total row const totalDisplay = quote.has_tbd ? `$${formatCurrency(quote.total)}*` : `$${formatCurrency(quote.total)}`; itemsHTML += ` This quote is valid for 14 days. We appreciate your business. Total ${totalDisplay} `; const tbdNote = quote.has_tbd && quote.tbd_note ? `

*${quote.tbd_note}

` : quote.has_tbd ? `

*Total excludes items marked as TBD which will be determined based on actual requirements.

` : ''; return ` Quote - Bay Area Affiliates, Inc.

Bay Area Affiliates, Inc.

1001 Blucher Street
Corpus Christi, Texas 78401

Providing IT Services and Support in South Texas Since 1996
Phone:
(361) 765-8400
(361) 765-8401
(361) 232-6578
Email:
support@bayarea-cc.com
Quote For:
${quote.customer_name}
${quote.street}
${quote.city}, ${quote.state} ${quote.zip_code}
QUOTE # ACCOUNT NO. DATE
${quote.quote_number} ${quote.account_number || ''} ${formatDate(quote.quote_date)}
${itemsHTML}
QTY DESCRIPTION RATE AMOUNT
${tbdNote}
`; } // 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); });