From 73e81442cce745a88f137d56d6863d919c75cd6d Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Sun, 1 Feb 2026 16:02:18 -0600 Subject: [PATCH] init --- .dockerignore | 15 + .env.example | 10 + .gitignore | 2 + CHANGELOG.md | 212 ++++++ Dockerfile | 41 ++ IMPLEMENTATION_SUMMARY.md | 294 ++++++++ INSTALLATION.md | 264 +++++++ README.md | 237 +++++++ add_invoices.sql | 38 + docker-compose.yml | 42 ++ init.sql | 53 ++ package.json | 19 + public/.gitkeep | 0 public/app.js | 825 +++++++++++++++++++++ public/index.html | 384 ++++++++++ server.js | 1418 +++++++++++++++++++++++++++++++++++++ setup.sh | 98 +++ 17 files changed, 3952 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 IMPLEMENTATION_SUMMARY.md create mode 100644 INSTALLATION.md create mode 100644 README.md create mode 100644 add_invoices.sql create mode 100644 docker-compose.yml create mode 100644 init.sql create mode 100644 package.json create mode 100644 public/.gitkeep create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 server.js create mode 100755 setup.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f99685c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +npm-debug.log +.env +.env.example +.git +.gitignore +*.md +README.md +INSTALLATION.md +setup.sh +docker-compose.yml +Dockerfile +.dockerignore +public/uploads/* +!public/uploads/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d8b6cc1 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_USER=quoteuser +DB_PASSWORD=your_secure_password_here +DB_NAME=quotes_db + +# Server Configuration +PORT=3000 +NODE_ENV=production diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b693a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.png \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eee7c77 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,212 @@ +# Changelog + +## Version 2.0.0 - Invoice System Implementation (2026-01-31) + +### Major New Features + +#### Invoice Management +- ✅ Full invoice creation and editing +- ✅ Invoice listing with customer names +- ✅ Invoice PDF generation with professional formatting +- ✅ Terms field (default: "Net 30") +- ✅ Authorization/P.O. field for purchase orders or authorization codes +- ✅ Automatic invoice numbering (YYYY-NNN format) +- ✅ Convert quotes to invoices with one click + +#### Quote to Invoice Conversion +- ✅ "→ Invoice" button on quote list +- ✅ Automatic validation (no TBD items allowed) +- ✅ One-click conversion preserving all quote data +- ✅ Automatic current date assignment +- ✅ Default terms applied ("Net 30") +- ✅ Links invoice to original quote + +#### PDF Differences +**Quotes:** +- Label: "Quote For:" +- Email: support@bayarea-cc.com +- Header info: Quote #, Account #, Date +- Allows TBD items with asterisk notation + +**Invoices:** +- Label: "Bill To:" +- Email: accounting@bayarea-cc.com +- Header info: Invoice #, Account #, Date, Terms +- No TBD items allowed +- Optional authorization field displayed + +### Database Changes + +#### New Tables +- `invoices` - Main invoice table +- `invoice_items` - Invoice line items + +#### New Columns in Invoices +- `invoice_number` - Unique invoice identifier +- `terms` - Payment terms (e.g., "Net 30") +- `authorization` - P.O. number or authorization code +- `created_from_quote_id` - Reference to original quote (if converted) + +#### Indexes Added +- `idx_invoices_invoice_number` +- `idx_invoices_customer_id` +- `idx_invoice_items_invoice_id` +- `idx_invoices_created_from_quote` + +### API Endpoints Added + +#### Invoice Endpoints +- `GET /api/invoices` - List all invoices +- `GET /api/invoices/:id` - Get invoice details +- `POST /api/invoices` - Create new invoice +- `PUT /api/invoices/:id` - Update invoice +- `DELETE /api/invoices/:id` - Delete invoice +- `GET /api/invoices/:id/pdf` - Generate invoice PDF + +#### Conversion Endpoint +- `POST /api/quotes/:id/convert-to-invoice` - Convert quote to invoice + +### UI Changes + +#### New Tab +- Added "Invoices" tab to navigation +- Invoice list view with all invoice details +- Terms column in invoice list + +#### New Modal +- Invoice creation/editing modal +- Terms input field +- Authorization input field +- Tax exempt checkbox +- Rich text description editor (Quill.js) + +#### Quote List Enhancement +- Added "→ Invoice" button to convert quotes +- Clear visual separation between quotes and invoices + +### Business Logic + +#### Validation Rules +- Quotes can have TBD items +- Invoices CANNOT have TBD items +- Conversion blocked if quote contains TBD items +- User receives clear error message for TBD conversion attempts + +#### Calculations +- Same tax rate (8.25%) for both quotes and invoices +- Tax exempt option available for both +- Automatic subtotal, tax, and total calculations + +#### Numbering +- Separate number sequences for quotes and invoices +- Both use YYYY-NNN format +- Auto-increment within calendar year +- Reset to 001 each January 1st + +### Files Modified +- `server.js` - Added invoice routes and PDF generation +- `public/app.js` - Added invoice management functions +- `public/index.html` - Added invoice tab and modal + +### Files Added +- `add_invoices.sql` - Database migration for invoices +- `INSTALLATION.md` - Detailed installation guide +- `CHANGELOG.md` - This file +- `docker-compose.yml` - Docker deployment configuration +- `Dockerfile` - Container image definition +- `.dockerignore` - Docker build exclusions + +### Migration Path + +For existing installations: + +1. Run the invoice migration: + ```sql + psql -U quoteuser -d quotes_db -f add_invoices.sql + ``` + +2. No changes to existing quotes data +3. Invoice numbering starts fresh (2026-001) +4. All existing features remain unchanged + +### Technical Details + +#### Invoice Number Generation +```javascript +async function getNextInvoiceNumber() { + const year = new Date().getFullYear(); + const result = await pool.query( + 'SELECT invoice_number FROM invoices WHERE invoice_number LIKE $1 ORDER BY invoice_number DESC LIMIT 1', + [`${year}-%`] + ); + + if (result.rows.length === 0) { + return `${year}-001`; + } + + const lastNumber = parseInt(result.rows[0].invoice_number.split('-')[1]); + const nextNumber = String(lastNumber + 1).padStart(3, '0'); + return `${year}-${nextNumber}`; +} +``` + +#### Conversion Validation +```javascript +// Check for TBD items +const hasTBD = itemsResult.rows.some(item => + item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD' +); + +if (hasTBD) { + return res.status(400).json({ + error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' + }); +} +``` + +### Backward Compatibility +- ✅ Fully backward compatible with existing quote system +- ✅ No breaking changes to quote functionality +- ✅ Existing PDFs continue to work +- ✅ Customer data unchanged + +### Testing Checklist +- [x] Create new invoice manually +- [x] Edit existing invoice +- [x] Delete invoice +- [x] Generate invoice PDF +- [x] Convert quote without TBD to invoice +- [x] Block conversion of quote with TBD items +- [x] Verify "Bill To:" label on invoice PDF +- [x] Verify accounting@bayarea-cc.com on invoice PDF +- [x] Verify terms display in PDF +- [x] Verify authorization display in PDF (when present) +- [x] Test tax calculations on invoices +- [x] Test tax-exempt invoices + +### Known Limitations +- None identified + +### Future Enhancements (Potential) +- Invoice payment tracking +- Partial payment support +- Invoice status (Paid/Unpaid/Overdue) +- Email delivery of PDFs +- Invoice reminders +- Multi-currency support +- Custom tax rates per customer + +--- + +## Version 1.0.0 - Initial Quote System + +### Features +- Quote creation and management +- Customer management +- PDF generation +- Rich text descriptions +- TBD item support +- Tax calculations +- Company logo upload + +See README.md for full documentation. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65bf601 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM node:18-alpine + +# Install Chromium and dependencies for Puppeteer +RUN apk add --no-cache \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + font-noto-emoji + +# Set Puppeteer to use installed Chromium +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --omit=dev + +# Copy application files +COPY server.js ./ +COPY public ./public + +# Create uploads directory +RUN mkdir -p public/uploads && \ + chmod 755 public/uploads + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/customers', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" + +# Start server +CMD ["node", "server.js"] diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..26df116 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,294 @@ +# Invoice System Implementation Summary + +## Übersicht / Overview + +Dieses Dokument fasst die komplette Invoice-System-Implementierung für Bay Area Affiliates zusammen. + +This document summarizes the complete Invoice System implementation for Bay Area Affiliates. + +--- + +## Was wurde implementiert / What Was Implemented + +### 1. Datenbank / Database ✅ +- **Neue Tabellen:** `invoices`, `invoice_items` +- **Neue Indizes:** Für Performance-Optimierung +- **Migration Script:** `add_invoices.sql` +- **Rückwärtskompatibel:** Keine Änderungen an bestehenden Quotes + +### 2. Backend (server.js) ✅ +- **Invoice CRUD Operationen:** + - GET /api/invoices - Liste aller Invoices + - GET /api/invoices/:id - Invoice Details + - POST /api/invoices - Neue Invoice erstellen + - PUT /api/invoices/:id - Invoice bearbeiten + - DELETE /api/invoices/:id - Invoice löschen + +- **PDF Generierung:** + - GET /api/invoices/:id/pdf - Invoice PDF + - "Bill To:" statt "Quote For:" + - accounting@bayarea-cc.com statt support@ + - Terms-Feld in Header-Tabelle + - Authorization-Feld (optional) + +- **Quote-zu-Invoice Konvertierung:** + - POST /api/quotes/:id/convert-to-invoice + - Validierung: Keine TBD-Items erlaubt + - Automatische Nummer-Generierung + - Verknüpfung mit Original-Quote + +### 3. Frontend (app.js) ✅ +- **Invoice Management:** + - loadInvoices() - Invoices laden + - renderInvoices() - Invoices anzeigen + - openInvoiceModal() - Modal für Create/Edit + - handleInvoiceSubmit() - Formular speichern + - addInvoiceItem() - Line Items hinzufügen + - updateInvoiceTotals() - Berechnungen + +- **Conversion Feature:** + - convertQuoteToInvoice() - Quote konvertieren + - Fehlerbehandlung für TBD-Items + +### 4. UI (index.html) ✅ +- **Neuer Tab:** "Invoices" in Navigation +- **Invoice-Liste:** Tabelle mit allen Invoices +- **Invoice Modal:** + - Customer Selection + - Date Picker + - Terms Input (default: "Net 30") + - Authorization Input (optional) + - Tax Exempt Checkbox + - Items mit Quill Rich Text Editor + - Totals Berechnung + +- **Quote-Liste Enhancement:** + - "→ Invoice" Button für Konvertierung + +### 5. Dokumentation ✅ +- **README.md:** Komplette Dokumentation +- **INSTALLATION.md:** Installations-Anleitung (DE/EN) +- **CHANGELOG.md:** Änderungsprotokoll +- **setup.sh:** Automatisches Setup-Script + +### 6. Deployment ✅ +- **Docker Support:** + - Dockerfile + - docker-compose.yml + - .dockerignore + +- **Environment:** + - .env.example + - Konfigurierbare Settings + +--- + +## Key Unterschiede: Quotes vs Invoices + +| Feature | Quotes | Invoices | +|---------|--------|----------| +| **TBD Items** | ✅ Erlaubt | ❌ Nicht erlaubt | +| **Email** | support@bayarea-cc.com | accounting@bayarea-cc.com | +| **Label** | "Quote For:" | "Bill To:" | +| **Terms** | Nein | Ja (z.B. "Net 30") | +| **Authorization** | Nein | Ja (optional, P.O. etc.) | +| **Header Info** | Quote #, Account #, Date | Invoice #, Account #, Date, Terms | +| **Konvertierung** | → zu Invoice | - | + +--- + +## Dateistruktur / File Structure + +``` +invoice-system/ +├── server.js # Express Backend mit allen Routes +├── public/ +│ ├── index.html # UI mit Tabs (Quotes/Invoices/Customers/Settings) +│ ├── app.js # Frontend JavaScript +│ └── uploads/ # Logo-Speicher +├── package.json # Dependencies +├── init.sql # Initial DB Schema (Customers, Quotes) +├── add_invoices.sql # Invoice Tables Migration +├── setup.sh # Auto-Installations-Script +├── .env.example # Environment Template +├── docker-compose.yml # Docker Deployment +├── Dockerfile # Container Image +├── README.md # Haupt-Dokumentation +├── INSTALLATION.md # Setup-Anleitung (DE/EN) +└── CHANGELOG.md # Versions-Historie +``` + +--- + +## Installation / Setup + +### Schnellstart / Quick Start + +```bash +# 1. Dateien entpacken +cd /installation/directory + +# 2. Setup ausführen +chmod +x setup.sh +./setup.sh + +# 3. Server starten +npm start + +# 4. Browser öffnen +# http://localhost:3000 +``` + +### Docker Deployment + +```bash +# Build und Start +docker-compose up -d + +# Logs ansehen +docker-compose logs -f + +# Stoppen +docker-compose down +``` + +--- + +## Validierungs-Regeln / Validation Rules + +### Quote zu Invoice Konvertierung + +**ERLAUBT / ALLOWED:** +```javascript +Quote Item: { qty: "2", rate: "125.00/hr", amount: "250.00" } +→ Kann konvertiert werden ✅ +``` + +**NICHT ERLAUBT / NOT ALLOWED:** +```javascript +Quote Item: { qty: "2", rate: "TBD", amount: "TBD" } +→ Fehler: "Cannot convert quote with TBD items to invoice" ❌ +``` + +**Lösung / Solution:** +1. Quote bearbeiten +2. TBD durch tatsächliche Werte ersetzen +3. Quote speichern +4. Dann konvertieren + +--- + +## API Beispiele / API Examples + +### Invoice erstellen / Create Invoice + +```javascript +POST /api/invoices +{ + "customer_id": 1, + "invoice_date": "2026-01-31", + "terms": "Net 30", + "authorization": "P.O. #12345", + "tax_exempt": false, + "items": [ + { + "quantity": "2", + "description": "

Email Hosting - Monthly

", + "rate": "25.00", + "amount": "50.00" + } + ] +} +``` + +### Quote zu Invoice / Quote to Invoice + +```javascript +POST /api/quotes/5/convert-to-invoice +// Response bei Erfolg: +{ + "id": 1, + "invoice_number": "2026-001", + "customer_id": 1, + "total": 54.13, + ... +} + +// Response bei TBD-Items: +{ + "error": "Cannot convert quote with TBD items to invoice. Please update all TBD items first." +} +``` + +--- + +## Testing Checklist ✅ + +- [x] Invoice erstellen +- [x] Invoice bearbeiten +- [x] Invoice löschen +- [x] Invoice PDF generieren +- [x] Quote ohne TBD zu Invoice konvertieren +- [x] Quote mit TBD Konvertierung blockieren +- [x] "Bill To:" Label im PDF +- [x] accounting@bayarea-cc.com im PDF +- [x] Terms im PDF Header +- [x] Authorization im PDF (wenn vorhanden) +- [x] Tax Berechnungen +- [x] Tax-Exempt Invoices +- [x] Customer Dropdown funktioniert +- [x] Auto-Numbering (2026-001, 2026-002, etc.) +- [x] Rich Text Editor in Items + +--- + +## Nächste Schritte / Next Steps + +### Deployment auf deinem Server +1. Dateien hochladen +2. `setup.sh` ausführen +3. Logo hochladen (Settings Tab) +4. Ersten Customer erstellen +5. Test-Quote erstellen +6. Quote zu Invoice konvertieren +7. PDFs testen + +### Optional: Docker +```bash +docker-compose up -d +``` + +### Backup einrichten +```bash +# Cronjob für tägliches Backup +0 2 * * * pg_dump -U quoteuser quotes_db > /backups/quotes_$(date +\%Y\%m\%d).sql +``` + +--- + +## Support & Hilfe + +- **Dokumentation:** README.md +- **Installation:** INSTALLATION.md +- **Änderungen:** CHANGELOG.md +- **Logs:** `journalctl -u quote-system -f` (systemd) +- **Docker Logs:** `docker-compose logs -f` + +--- + +## Zusammenfassung / Summary + +**Vollständiges Invoice-System implementiert mit:** +- ✅ Separate Invoice-Verwaltung +- ✅ Quote-zu-Invoice Konvertierung +- ✅ TBD-Validierung +- ✅ Professionelle PDFs +- ✅ Unterschiedliche Email-Adressen +- ✅ Terms & Authorization Felder +- ✅ Automatische Nummerierung +- ✅ Vollständige Dokumentation +- ✅ Docker Support +- ✅ Auto-Setup Script +- ✅ Rückwärtskompatibel + +**Bereit für Produktion!** 🚀 diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..4cb8c59 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,264 @@ +# Installation Guide / Installationsanleitung + +## English Version + +### Prerequisites +- Node.js 18 or higher +- PostgreSQL 12 or higher +- npm (comes with Node.js) + +### Quick Installation + +1. **Extract files to your server** + ```bash + cd /your/installation/directory + ``` + +2. **Run the setup script** + ```bash + chmod +x setup.sh + ./setup.sh + ``` + + The script will: + - Create the PostgreSQL database and user + - Set up environment variables + - Run database migrations + - Install Node.js dependencies + - Create necessary directories + +3. **Start the server** + ```bash + npm start + ``` + +4. **Access the application** + Open your browser to: http://localhost:3000 + +### Manual Installation + +If you prefer to install manually: + +1. **Create PostgreSQL database** + ```bash + sudo -u postgres psql + CREATE DATABASE quotes_db; + CREATE USER quoteuser WITH PASSWORD 'your_password'; + GRANT ALL PRIVILEGES ON DATABASE quotes_db TO quoteuser; + \q + ``` + +2. **Run database migrations** + ```bash + psql -U quoteuser -d quotes_db -f init.sql + psql -U quoteuser -d quotes_db -f add_invoices.sql + ``` + +3. **Install dependencies** + ```bash + npm install + ``` + +4. **Configure environment** + ```bash + cp .env.example .env + # Edit .env with your settings + ``` + +5. **Create directories** + ```bash + mkdir -p public/uploads + ``` + +6. **Start the server** + ```bash + npm start + ``` + +--- + +## Deutsche Version + +### Voraussetzungen +- Node.js 18 oder höher +- PostgreSQL 12 oder höher +- npm (kommt mit Node.js) + +### Schnell-Installation + +1. **Dateien auf deinen Server entpacken** + ```bash + cd /dein/installations/verzeichnis + ``` + +2. **Setup-Script ausführen** + ```bash + chmod +x setup.sh + ./setup.sh + ``` + + Das Script wird: + - PostgreSQL-Datenbank und Benutzer erstellen + - Umgebungsvariablen einrichten + - Datenbank-Migrationen ausführen + - Node.js-Abhängigkeiten installieren + - Notwendige Verzeichnisse erstellen + +3. **Server starten** + ```bash + npm start + ``` + +4. **Anwendung öffnen** + Browser öffnen: http://localhost:3000 + +### Manuelle Installation + +Falls du lieber manuell installieren möchtest: + +1. **PostgreSQL-Datenbank erstellen** + ```bash + sudo -u postgres psql + CREATE DATABASE quotes_db; + CREATE USER quoteuser WITH PASSWORD 'dein_passwort'; + GRANT ALL PRIVILEGES ON DATABASE quotes_db TO quoteuser; + \q + ``` + +2. **Datenbank-Migrationen ausführen** + ```bash + psql -U quoteuser -d quotes_db -f init.sql + psql -U quoteuser -d quotes_db -f add_invoices.sql + ``` + +3. **Abhängigkeiten installieren** + ```bash + npm install + ``` + +4. **Umgebung konfigurieren** + ```bash + cp .env.example .env + # .env mit deinen Einstellungen bearbeiten + ``` + +5. **Verzeichnisse erstellen** + ```bash + mkdir -p public/uploads + ``` + +6. **Server starten** + ```bash + npm start + ``` + +--- + +## File Structure / Dateistruktur + +``` +quote-invoice-system/ +├── server.js # Express server / Backend-Server +├── public/ +│ ├── index.html # Main UI / Hauptoberfläche +│ ├── app.js # Frontend JavaScript +│ └── uploads/ # Logo storage / Logo-Speicher +├── package.json # Dependencies / Abhängigkeiten +├── init.sql # Initial DB schema / Initiales DB-Schema +├── add_invoices.sql # Invoice tables / Rechnungs-Tabellen +├── setup.sh # Auto-installation / Auto-Installation +├── .env.example # Environment template / Umgebungs-Vorlage +└── README.md # Documentation / Dokumentation +``` + +## Troubleshooting / Fehlerbehebung + +### Database connection fails / Datenbankverbindung fehlgeschlagen +- Check PostgreSQL is running: `sudo systemctl status postgresql` +- Verify credentials in `.env` file +- Ensure user has permissions: `GRANT ALL ON SCHEMA public TO quoteuser;` + +### Port 3000 already in use / Port 3000 bereits belegt +- Change `PORT` in `.env` file +- Or stop the service using port 3000 + +### PDF generation fails / PDF-Generierung fehlgeschlagen +- Puppeteer requires Chromium +- On Ubuntu/Debian: `sudo apt-get install chromium-browser` +- On Alpine/Docker: Already configured in Dockerfile + +### Permission errors / Berechtigungsfehler +- Ensure `public/uploads` directory exists and is writable +- Run: `chmod 755 public/uploads` + +## Production Deployment / Produktions-Deployment + +### Using PM2 (Recommended / Empfohlen) + +```bash +# Install PM2 +npm install -g pm2 + +# Start application +pm2 start server.js --name quote-system + +# Save PM2 configuration +pm2 save + +# Auto-start on boot +pm2 startup +``` + +### Using systemd + +Create `/etc/systemd/system/quote-system.service`: + +```ini +[Unit] +Description=Quote & Invoice System +After=network.target postgresql.service + +[Service] +Type=simple +User=your_user +WorkingDirectory=/path/to/quote-invoice-system +Environment="NODE_ENV=production" +ExecStart=/usr/bin/node server.js +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Then: +```bash +sudo systemctl enable quote-system +sudo systemctl start quote-system +``` + +## Security Recommendations / Sicherheitsempfehlungen + +1. **Use strong database passwords / Starke Datenbank-Passwörter verwenden** +2. **Run behind reverse proxy (nginx) / Hinter Reverse-Proxy betreiben** +3. **Enable HTTPS / HTTPS aktivieren** +4. **Regular backups / Regelmäßige Backups** +5. **Keep dependencies updated / Abhängigkeiten aktuell halten** + +## Backup / Sicherung + +### Database Backup / Datenbank-Backup +```bash +pg_dump -U quoteuser quotes_db > backup_$(date +%Y%m%d).sql +``` + +### Restore / Wiederherstellen +```bash +psql -U quoteuser quotes_db < backup_20260131.sql +``` + +## Support + +For technical support / Für technischen Support: +- Check README.md for usage instructions +- Review error logs: `journalctl -u quote-system -f` +- Contact Bay Area Affiliates, Inc. diff --git a/README.md b/README.md new file mode 100644 index 0000000..284f772 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# Quote & Invoice Management System + +Professional quote and invoice management system for Bay Area Affiliates, Inc. + +## Features + +### Quotes +- Create and manage professional quotes +- Support for TBD (To Be Determined) items +- Rich text descriptions with Quill editor +- Automatic tax calculations (8.25% Texas sales tax) +- Tax-exempt option +- PDF generation +- **Convert quotes to invoices** with one click + +### Invoices +- Create and manage invoices +- Terms field (default: Net 30) +- Authorization/P.O. field +- Rich text descriptions +- Automatic tax calculations +- Tax-exempt option +- PDF generation with accounting@bayarea-cc.com email +- "Bill To:" instead of "Quote For:" +- **No TBD items allowed** - quotes with TBD items cannot be converted until updated + +### Customers +- Full customer management +- Address and account number tracking +- Customer selection in quotes/invoices + +### Settings +- Company logo upload for PDFs +- Logo appears on both quotes and invoices + +## Installation + +### Prerequisites +- Node.js 18+ +- PostgreSQL 12+ +- npm or yarn + +### Setup + +1. **Clone or copy the files to your server** + +2. **Install dependencies:** +```bash +npm install +``` + +3. **Set up PostgreSQL database:** +```bash +# Create database and user +createdb quotes_db +createuser -P quoteuser # Enter password when prompted +``` + +4. **Run database migrations:** + +First, run the initial setup: +```sql +-- In psql or your PostgreSQL client, run init.sql +psql -U quoteuser -d quotes_db -f init.sql +``` + +Then add invoice tables: +```sql +-- Run add_invoices.sql +psql -U quoteuser -d quotes_db -f add_invoices.sql +``` + +5. **Configure environment (optional):** + +Create a `.env` file or set environment variables: +```bash +DB_USER=quoteuser +DB_PASSWORD=your_password +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=quotes_db +PORT=3000 +``` + +6. **Start the server:** +```bash +npm start +``` + +For development with auto-reload: +```bash +npm run dev +``` + +7. **Access the application:** +Open your browser to: `http://localhost:3000` + +## Database Schema + +### Customers Table +- id, name, street, city, state, zip_code, account_number + +### Quotes Table +- id, quote_number, customer_id, quote_date, tax_exempt, tax_rate +- subtotal, tax_amount, total, has_tbd, created_at, updated_at + +### Quote Items Table +- id, quote_id, quantity, description, rate, amount, item_order + +### Invoices Table +- id, invoice_number, customer_id, invoice_date, terms, authorization +- tax_exempt, tax_rate, subtotal, tax_amount, total +- created_from_quote_id, created_at, updated_at + +### Invoice Items Table +- id, invoice_id, quantity, description, rate, amount, item_order + +## Usage + +### Creating a Quote +1. Click "Quotes" tab +2. Click "+ New Quote" +3. Select customer +4. Add items (can use TBD for rates/amounts) +5. Items support rich text formatting +6. Check "Tax Exempt" if applicable +7. Save + +### Converting Quote to Invoice +1. Find quote in list +2. Click "→ Invoice" button +3. **Note:** Quotes with TBD items cannot be converted +4. Invoice is automatically created with: + - Same customer and items + - Current date + - Default terms: "Net 30" + - Empty authorization field + +### Creating an Invoice +1. Click "Invoices" tab +2. Click "+ New Invoice" +3. Select customer +4. Enter terms (e.g., "Net 30", "Due on Receipt") +5. Add authorization if needed (P.O. number, etc.) +6. Add items (TBD not allowed in invoices) +7. Check "Tax Exempt" if applicable +8. Save + +### PDF Generation +- **Quotes:** Display "Quote For:" and support@bayarea-cc.com +- **Invoices:** Display "Bill To:" and accounting@bayarea-cc.com +- Both include company logo if uploaded +- Professional formatting with Bay Area Affiliates branding + +### Managing Customers +1. Click "Customers" tab +2. Add/Edit/Delete customers +3. Customers appear in dropdown for quotes/invoices + +### Settings +1. Click "Settings" tab +2. Upload company logo (PNG/JPG recommended) +3. Logo appears on all PDFs + +## File Structure + +``` +quote-invoice-system/ +├── server.js # Express server with all routes +├── public/ +│ ├── index.html # Main UI with tabs for quotes/invoices +│ ├── app.js # Frontend JavaScript +│ └── uploads/ # Logo storage +├── package.json # Dependencies +├── init.sql # Initial database schema +└── add_invoices.sql # Invoice tables migration +``` + +## Key Differences: Quotes vs Invoices + +| Feature | Quotes | Invoices | +|---------|--------|----------| +| TBD Items | ✅ Allowed | ❌ Not allowed | +| Email | support@bayarea-cc.com | accounting@bayarea-cc.com | +| Label | "Quote For:" | "Bill To:" | +| Terms Field | No | Yes (e.g., Net 30) | +| Authorization | No | Yes (optional) | +| Info Header | Quote #, Account #, Date | Invoice #, Account #, Date, Terms | + +## API Endpoints + +### Quotes +- `GET /api/quotes` - List all quotes +- `GET /api/quotes/:id` - Get quote details +- `POST /api/quotes` - Create new quote +- `PUT /api/quotes/:id` - Update quote +- `DELETE /api/quotes/:id` - Delete quote +- `GET /api/quotes/:id/pdf` - Generate quote PDF +- `POST /api/quotes/:id/convert-to-invoice` - Convert to invoice + +### Invoices +- `GET /api/invoices` - List all invoices +- `GET /api/invoices/:id` - Get invoice details +- `POST /api/invoices` - Create new invoice +- `PUT /api/invoices/:id` - Update invoice +- `DELETE /api/invoices/:id` - Delete invoice +- `GET /api/invoices/:id/pdf` - Generate invoice PDF + +### Customers +- `GET /api/customers` - List all customers +- `POST /api/customers` - Create customer +- `PUT /api/customers/:id` - Update customer +- `DELETE /api/customers/:id` - Delete customer + +### Settings +- `GET /api/logo-info` - Check if logo exists +- `POST /api/upload-logo` - Upload company logo + +## Technical Details + +- **Frontend:** Vanilla JavaScript, Tailwind CSS +- **Backend:** Node.js, Express +- **Database:** PostgreSQL +- **PDF Generation:** Puppeteer +- **Rich Text:** Quill.js editor + +## Automatic Features + +- Quote numbers: Format YYYY-NNN (e.g., 2026-001) +- Invoice numbers: Format YYYY-NNN (e.g., 2026-001) +- Auto-increment within year +- Automatic tax calculation (8.25%) +- Item quantity × rate = amount calculation + +## Support + +For issues or questions, contact Bay Area Affiliates, Inc. diff --git a/add_invoices.sql b/add_invoices.sql new file mode 100644 index 0000000..c9bd72e --- /dev/null +++ b/add_invoices.sql @@ -0,0 +1,38 @@ +-- Migration to add Invoice functionality +-- Run this on your existing database + +-- Create invoices table +CREATE TABLE IF NOT EXISTS invoices ( + id SERIAL PRIMARY KEY, + invoice_number VARCHAR(50) UNIQUE NOT NULL, + customer_id INTEGER REFERENCES customers(id), + invoice_date DATE NOT NULL, + terms VARCHAR(100) DEFAULT 'Net 30', + auth_code VARCHAR(255), + tax_exempt BOOLEAN DEFAULT FALSE, + tax_rate DECIMAL(5,2) DEFAULT 8.25, + subtotal DECIMAL(10,2) DEFAULT 0, + tax_amount DECIMAL(10,2) DEFAULT 0, + total DECIMAL(10,2) DEFAULT 0, + created_from_quote_id INTEGER REFERENCES quotes(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create invoice_items table +CREATE TABLE IF NOT EXISTS invoice_items ( + id SERIAL PRIMARY KEY, + invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE, + quantity VARCHAR(20) NOT NULL, + description TEXT NOT NULL, + rate VARCHAR(50) NOT NULL, + amount VARCHAR(50) NOT NULL, + item_order INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_invoices_invoice_number ON invoices(invoice_number); +CREATE INDEX IF NOT EXISTS idx_invoices_customer_id ON invoices(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice_id ON invoice_items(invoice_id); +CREATE INDEX IF NOT EXISTS idx_invoices_created_from_quote ON invoices(created_from_quote_id); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b716772 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + postgres: + image: postgres:15-alpine + container_name: quote_postgres + environment: + POSTGRES_USER: quoteuser + POSTGRES_PASSWORD: quotepass123 + POSTGRES_DB: quotes_db + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/01-init.sql + - ./add_invoices.sql:/docker-entrypoint-initdb.d/02-add-invoices.sql + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U quoteuser -d quotes_db"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + app: + build: . + container_name: quote_app + ports: + - "3000:3000" + environment: + NODE_ENV: production + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: quoteuser + DB_PASSWORD: quotepass123 + DB_NAME: quotes_db + volumes: + - ./public/uploads:/app/public/uploads + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres_data: diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..62cd10b --- /dev/null +++ b/init.sql @@ -0,0 +1,53 @@ +-- Initial Database Setup for Quote & Invoice System +-- Run this first to create the basic tables + +-- Create customers table +CREATE TABLE IF NOT EXISTS customers ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + street VARCHAR(255) NOT NULL, + city VARCHAR(100) NOT NULL, + state VARCHAR(2) NOT NULL, + zip_code VARCHAR(10) NOT NULL, + account_number VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create quotes table +CREATE TABLE IF NOT EXISTS quotes ( + id SERIAL PRIMARY KEY, + quote_number VARCHAR(50) UNIQUE NOT NULL, + customer_id INTEGER REFERENCES customers(id), + quote_date DATE NOT NULL, + tax_exempt BOOLEAN DEFAULT FALSE, + tax_rate DECIMAL(5,2) DEFAULT 8.25, + subtotal DECIMAL(10,2) DEFAULT 0, + tax_amount DECIMAL(10,2) DEFAULT 0, + total DECIMAL(10,2) DEFAULT 0, + has_tbd BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create quote_items table +CREATE TABLE IF NOT EXISTS quote_items ( + id SERIAL PRIMARY KEY, + quote_id INTEGER REFERENCES quotes(id) ON DELETE CASCADE, + quantity VARCHAR(20) NOT NULL, + description TEXT NOT NULL, + rate VARCHAR(50) NOT NULL, + amount VARCHAR(50) NOT NULL, + item_order INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_quotes_quote_number ON quotes(quote_number); +CREATE INDEX IF NOT EXISTS idx_quotes_customer_id ON quotes(customer_id); +CREATE INDEX IF NOT EXISTS idx_quote_items_quote_id ON quote_items(quote_id); + +-- Insert sample customer +INSERT INTO customers (name, street, city, state, zip_code, account_number) +VALUES ('Braselton Development', '5337 Yorktown Blvd. Suite 10-D', 'Corpus Christi', 'TX', '78414', '3617790060') +ON CONFLICT DO NOTHING; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a66b64d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "quote-invoice-system", + "version": "2.0.0", + "description": "Quote & Invoice Management System for Bay Area Affiliates", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.21.2", + "pg": "^8.13.1", + "multer": "^1.4.5-lts.1", + "puppeteer": "^23.11.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..35735c2 --- /dev/null +++ b/public/app.js @@ -0,0 +1,825 @@ +// Global state +let customers = []; +let quotes = []; +let invoices = []; +let currentQuoteId = null; +let currentInvoiceId = null; +let currentCustomerId = null; +let itemCounter = 0; +let currentLogoFile = null; + +// Initialize app +document.addEventListener('DOMContentLoaded', () => { + loadCustomers(); + loadQuotes(); + loadInvoices(); + setDefaultDate(); + checkCurrentLogo(); + + // Setup form handlers + document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit); + document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit); + document.getElementById('invoice-form').addEventListener('submit', handleInvoiceSubmit); + document.getElementById('quote-tax-exempt').addEventListener('change', updateQuoteTotals); + document.getElementById('invoice-tax-exempt').addEventListener('change', updateInvoiceTotals); + + // Setup logo upload handler + document.getElementById('logo-upload').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + currentLogoFile = file; + document.getElementById('logo-filename').textContent = file.name; + document.getElementById('upload-btn').disabled = false; + } + }); +}); + +// Tab Management +function showTab(tabName) { + document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden')); + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800')); + + document.getElementById(`${tabName}-tab`).classList.remove('hidden'); + document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800'); + + if (tabName === 'quotes') { + loadQuotes(); + } else if (tabName === 'invoices') { + loadInvoices(); + } else if (tabName === 'customers') { + loadCustomers(); + } else if (tabName === 'settings') { + checkCurrentLogo(); + } +} + +// Date helper +function setDefaultDate() { + const today = new Date().toISOString().split('T')[0]; + const quoteDateEl = document.getElementById('quote-date'); + const invoiceDateEl = document.getElementById('invoice-date'); + if (quoteDateEl) quoteDateEl.value = today; + if (invoiceDateEl) invoiceDateEl.value = today; +} + +function formatDate(date) { + const d = new Date(date); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const year = d.getFullYear(); + return `${month}/${day}/${year}`; +} + +// Logo Management +async function checkCurrentLogo() { + try { + const response = await fetch('/api/logo-info'); + if (response.ok) { + const data = await response.json(); + if (data.hasLogo) { + document.getElementById('logo-preview').classList.remove('hidden'); + document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now(); + } + } + } catch (error) { + console.error('Error checking logo:', error); + } +} + +async function uploadLogo() { + if (!currentLogoFile) { + alert('Please select a file first'); + return; + } + + const formData = new FormData(); + formData.append('logo', currentLogoFile); + + const statusDiv = document.getElementById('upload-status'); + statusDiv.innerHTML = '

Uploading...

'; + + try { + const response = await fetch('/api/upload-logo', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const data = await response.json(); + statusDiv.innerHTML = '

✓ Logo uploaded successfully!

'; + document.getElementById('logo-preview').classList.remove('hidden'); + document.getElementById('logo-image').src = data.path + '?t=' + Date.now(); + document.getElementById('upload-btn').disabled = true; + currentLogoFile = null; + document.getElementById('logo-filename').textContent = ''; + document.getElementById('logo-upload').value = ''; + } else { + const error = await response.json(); + statusDiv.innerHTML = `

✗ Error: ${error.error}

`; + } + } catch (error) { + console.error('Upload error:', error); + statusDiv.innerHTML = '

✗ Upload failed

'; + } +} + +// Customer Management +async function loadCustomers() { + try { + const response = await fetch('/api/customers'); + customers = await response.json(); + renderCustomers(); + updateCustomerDropdown(); + } catch (error) { + console.error('Error loading customers:', error); + alert('Error loading customers'); + } +} + +function renderCustomers() { + const tbody = document.getElementById('customers-list'); + tbody.innerHTML = customers.map(customer => ` + + ${customer.name} + ${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code} + ${customer.account_number || '-'} + + + + + + `).join(''); +} + +function updateCustomerDropdown() { + const quoteSelect = document.getElementById('quote-customer'); + const invoiceSelect = document.getElementById('invoice-customer'); + + const options = '' + + customers.map(c => ``).join(''); + + if (quoteSelect) quoteSelect.innerHTML = options; + if (invoiceSelect) invoiceSelect.innerHTML = options; +} + +function openCustomerModal(customerId = null) { + currentCustomerId = customerId; + const modal = document.getElementById('customer-modal'); + const title = document.getElementById('customer-modal-title'); + + if (customerId) { + title.textContent = 'Edit Customer'; + const customer = customers.find(c => c.id === customerId); + document.getElementById('customer-id').value = customer.id; + document.getElementById('customer-name').value = customer.name; + document.getElementById('customer-street').value = customer.street; + document.getElementById('customer-city').value = customer.city; + document.getElementById('customer-state').value = customer.state; + document.getElementById('customer-zip').value = customer.zip_code; + document.getElementById('customer-account').value = customer.account_number || ''; + } else { + title.textContent = 'New Customer'; + document.getElementById('customer-form').reset(); + document.getElementById('customer-id').value = ''; + } + + modal.classList.add('active'); +} + +function closeCustomerModal() { + document.getElementById('customer-modal').classList.remove('active'); + currentCustomerId = null; +} + +async function handleCustomerSubmit(e) { + e.preventDefault(); + + const data = { + name: document.getElementById('customer-name').value, + street: document.getElementById('customer-street').value, + city: document.getElementById('customer-city').value, + state: document.getElementById('customer-state').value.toUpperCase(), + zip_code: document.getElementById('customer-zip').value, + account_number: document.getElementById('customer-account').value + }; + + try { + const customerId = document.getElementById('customer-id').value; + const url = customerId ? `/api/customers/${customerId}` : '/api/customers'; + const method = customerId ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeCustomerModal(); + loadCustomers(); + } else { + alert('Error saving customer'); + } + } catch (error) { + console.error('Error:', error); + alert('Error saving customer'); + } +} + +async function editCustomer(id) { + openCustomerModal(id); +} + +async function deleteCustomer(id) { + if (!confirm('Are you sure you want to delete this customer?')) return; + + try { + const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadCustomers(); + } else { + alert('Error deleting customer'); + } + } catch (error) { + console.error('Error:', error); + alert('Error deleting customer'); + } +} + +// Quote Management +async function loadQuotes() { + try { + const response = await fetch('/api/quotes'); + quotes = await response.json(); + renderQuotes(); + } catch (error) { + console.error('Error loading quotes:', error); + alert('Error loading quotes'); + } +} + +function renderQuotes() { + const tbody = document.getElementById('quotes-list'); + tbody.innerHTML = quotes.map(quote => { + const total = quote.has_tbd ? `$${parseFloat(quote.total).toFixed(2)}*` : `$${parseFloat(quote.total).toFixed(2)}`; + return ` + + ${quote.quote_number} + ${quote.customer_name || 'N/A'} + ${formatDate(quote.quote_date)} + ${total} + + + + + + + + `; + }).join(''); +} + +async function openQuoteModal(quoteId = null) { + currentQuoteId = quoteId; + const modal = document.getElementById('quote-modal'); + const title = document.getElementById('quote-modal-title'); + + if (quoteId) { + title.textContent = 'Edit Quote'; + const response = await fetch(`/api/quotes/${quoteId}`); + const data = await response.json(); + + document.getElementById('quote-customer').value = data.quote.customer_id; + const dateOnly = data.quote.quote_date.split('T')[0]; + document.getElementById('quote-date').value = dateOnly; + document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt; + + // Load items + document.getElementById('quote-items').innerHTML = ''; + itemCounter = 0; + data.items.forEach(item => { + addQuoteItem(item); + }); + + updateQuoteTotals(); + } else { + title.textContent = 'New Quote'; + document.getElementById('quote-form').reset(); + document.getElementById('quote-items').innerHTML = ''; + itemCounter = 0; + setDefaultDate(); + + // Add one default item + addQuoteItem(); + } + + modal.classList.add('active'); +} + +function closeQuoteModal() { + document.getElementById('quote-modal').classList.remove('active'); + currentQuoteId = null; +} + +function addQuoteItem(item = null) { + const itemId = itemCounter++; + const itemsDiv = document.getElementById('quote-items'); + + const itemDiv = document.createElement('div'); + itemDiv.className = 'grid grid-cols-12 gap-3 items-start mb-3'; + itemDiv.id = `quote-item-${itemId}`; + + itemDiv.innerHTML = ` +
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ `; + + itemsDiv.appendChild(itemDiv); + + // Initialize Quill editor + const editorDiv = itemDiv.querySelector('.quote-item-description-editor'); + const quill = new Quill(editorDiv, { + theme: 'snow', + modules: { + toolbar: [ + ['bold', 'italic', 'underline'], + [{ 'list': 'ordered'}, { 'list': 'bullet' }], + ['clean'] + ] + } + }); + + if (item && item.description) { + quill.root.innerHTML = item.description; + } + + quill.on('text-change', () => { + updateQuoteTotals(); + }); + + editorDiv.quillInstance = quill; + + // Auto-calculate amount + const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); + const rateInput = itemDiv.querySelector('[data-field="rate"]'); + const amountInput = itemDiv.querySelector('[data-field="amount"]'); + + const calculateAmount = () => { + if (qtyInput.value && rateInput.value && rateInput.value.toUpperCase() !== 'TBD') { + const qty = parseFloat(qtyInput.value) || 0; + const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0; + amountInput.value = (qty * rateValue).toFixed(2); + } + updateQuoteTotals(); + }; + + qtyInput.addEventListener('input', calculateAmount); + rateInput.addEventListener('input', calculateAmount); + amountInput.addEventListener('input', updateQuoteTotals); + + updateQuoteTotals(); +} + +function removeQuoteItem(itemId) { + document.getElementById(`quote-item-${itemId}`).remove(); + updateQuoteTotals(); +} + +function updateQuoteTotals() { + const items = getQuoteItems(); + const taxExempt = document.getElementById('quote-tax-exempt').checked; + + let subtotal = 0; + let hasTbd = false; + + items.forEach(item => { + if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') { + hasTbd = true; + } else { + const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0; + subtotal += amount; + } + }); + + const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100); + const total = subtotal + taxAmount; + + document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`; + document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`; + document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`; + + document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block'; +} + +function getQuoteItems() { + const items = []; + const itemDivs = document.querySelectorAll('#quote-items > div'); + + itemDivs.forEach(div => { + const descEditor = div.querySelector('.quote-item-description-editor'); + const descriptionHTML = descEditor && descEditor.quillInstance + ? descEditor.quillInstance.root.innerHTML + : ''; + + const item = { + quantity: div.querySelector('[data-field="quantity"]').value, + description: descriptionHTML, + rate: div.querySelector('[data-field="rate"]').value, + amount: div.querySelector('[data-field="amount"]').value + }; + items.push(item); + }); + + return items; +} + +async function handleQuoteSubmit(e) { + e.preventDefault(); + + const items = getQuoteItems(); + + if (items.length === 0) { + alert('Please add at least one item'); + return; + } + + const data = { + customer_id: parseInt(document.getElementById('quote-customer').value), + quote_date: document.getElementById('quote-date').value, + tax_exempt: document.getElementById('quote-tax-exempt').checked, + items: items + }; + + try { + const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes'; + const method = currentQuoteId ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeQuoteModal(); + loadQuotes(); + } else { + alert('Error saving quote'); + } + } catch (error) { + console.error('Error:', error); + alert('Error saving quote'); + } +} + +async function editQuote(id) { + await openQuoteModal(id); +} + +async function deleteQuote(id) { + if (!confirm('Are you sure you want to delete this quote?')) return; + + try { + const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadQuotes(); + } else { + alert('Error deleting quote'); + } + } catch (error) { + console.error('Error:', error); + alert('Error deleting quote'); + } +} + +function viewQuotePDF(id) { + window.open(`/api/quotes/${id}/pdf`, '_blank'); +} + +async function convertQuoteToInvoice(quoteId) { + if (!confirm('Convert this quote to an invoice?')) return; + + try { + const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, { + method: 'POST' + }); + + if (response.ok) { + const invoice = await response.json(); + alert(`Invoice ${invoice.invoice_number} created successfully!`); + loadInvoices(); + showTab('invoices'); + } else { + const error = await response.json(); + alert(error.error || 'Error converting quote to invoice'); + } + } catch (error) { + console.error('Error:', error); + alert('Error converting quote to invoice'); + } +} + +// Invoice Management +async function loadInvoices() { + try { + const response = await fetch('/api/invoices'); + invoices = await response.json(); + renderInvoices(); + } catch (error) { + console.error('Error loading invoices:', error); + alert('Error loading invoices'); + } +} + +function renderInvoices() { + const tbody = document.getElementById('invoices-list'); + tbody.innerHTML = invoices.map(invoice => ` + + ${invoice.invoice_number} + ${invoice.customer_name || 'N/A'} + ${formatDate(invoice.invoice_date)} + ${invoice.terms} + $${parseFloat(invoice.total).toFixed(2)} + + + + + + + `).join(''); +} + +async function openInvoiceModal(invoiceId = null) { + currentInvoiceId = invoiceId; + const modal = document.getElementById('invoice-modal'); + const title = document.getElementById('invoice-modal-title'); + + if (invoiceId) { + title.textContent = 'Edit Invoice'; + const response = await fetch(`/api/invoices/${invoiceId}`); + const data = await response.json(); + + document.getElementById('invoice-customer').value = data.invoice.customer_id; + const dateOnly = data.invoice.invoice_date.split('T')[0]; + document.getElementById('invoice-date').value = dateOnly; + document.getElementById('invoice-terms').value = data.invoice.terms; + document.getElementById('invoice-authorization').value = data.invoice.auth_code || ''; + document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt; + + // Load items + document.getElementById('invoice-items').innerHTML = ''; + itemCounter = 0; + data.items.forEach(item => { + addInvoiceItem(item); + }); + + updateInvoiceTotals(); + } else { + title.textContent = 'New Invoice'; + document.getElementById('invoice-form').reset(); + document.getElementById('invoice-items').innerHTML = ''; + document.getElementById('invoice-terms').value = 'Net 30'; + itemCounter = 0; + setDefaultDate(); + + // Add one default item + addInvoiceItem(); + } + + modal.classList.add('active'); +} + +function closeInvoiceModal() { + document.getElementById('invoice-modal').classList.remove('active'); + currentInvoiceId = null; +} + +function addInvoiceItem(item = null) { + const itemId = itemCounter++; + const itemsDiv = document.getElementById('invoice-items'); + + const itemDiv = document.createElement('div'); + itemDiv.className = 'grid grid-cols-12 gap-3 items-start mb-3'; + itemDiv.id = `invoice-item-${itemId}`; + + itemDiv.innerHTML = ` +
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ `; + + itemsDiv.appendChild(itemDiv); + + // Initialize Quill editor + const editorDiv = itemDiv.querySelector('.invoice-item-description-editor'); + const quill = new Quill(editorDiv, { + theme: 'snow', + modules: { + toolbar: [ + ['bold', 'italic', 'underline'], + [{ 'list': 'ordered'}, { 'list': 'bullet' }], + ['clean'] + ] + } + }); + + if (item && item.description) { + quill.root.innerHTML = item.description; + } + + quill.on('text-change', () => { + updateInvoiceTotals(); + }); + + editorDiv.quillInstance = quill; + + // Auto-calculate amount + const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); + const rateInput = itemDiv.querySelector('[data-field="rate"]'); + const amountInput = itemDiv.querySelector('[data-field="amount"]'); + + const calculateAmount = () => { + if (qtyInput.value && rateInput.value) { + const qty = parseFloat(qtyInput.value) || 0; + const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0; + amountInput.value = (qty * rateValue).toFixed(2); + } + updateInvoiceTotals(); + }; + + qtyInput.addEventListener('input', calculateAmount); + rateInput.addEventListener('input', calculateAmount); + amountInput.addEventListener('input', updateInvoiceTotals); + + updateInvoiceTotals(); +} + +function removeInvoiceItem(itemId) { + document.getElementById(`invoice-item-${itemId}`).remove(); + updateInvoiceTotals(); +} + +function updateInvoiceTotals() { + const items = getInvoiceItems(); + const taxExempt = document.getElementById('invoice-tax-exempt').checked; + + let subtotal = 0; + + items.forEach(item => { + const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0; + subtotal += amount; + }); + + const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100); + const total = subtotal + taxAmount; + + document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`; + document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`; + document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`; + + document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block'; +} + +function getInvoiceItems() { + const items = []; + const itemDivs = document.querySelectorAll('#invoice-items > div'); + + itemDivs.forEach(div => { + const descEditor = div.querySelector('.invoice-item-description-editor'); + const descriptionHTML = descEditor && descEditor.quillInstance + ? descEditor.quillInstance.root.innerHTML + : ''; + + const item = { + quantity: div.querySelector('[data-field="quantity"]').value, + description: descriptionHTML, + rate: div.querySelector('[data-field="rate"]').value, + amount: div.querySelector('[data-field="amount"]').value + }; + items.push(item); + }); + + return items; +} + +async function handleInvoiceSubmit(e) { + e.preventDefault(); + + const items = getInvoiceItems(); + + if (items.length === 0) { + alert('Please add at least one item'); + return; + } + + const data = { + customer_id: parseInt(document.getElementById('invoice-customer').value), + invoice_date: document.getElementById('invoice-date').value, + terms: document.getElementById('invoice-terms').value, + auth_code: document.getElementById('invoice-authorization').value, + tax_exempt: document.getElementById('invoice-tax-exempt').checked, + items: items + }; + + try { + const url = currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : '/api/invoices'; + const method = currentInvoiceId ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeInvoiceModal(); + loadInvoices(); + } else { + alert('Error saving invoice'); + } + } catch (error) { + console.error('Error:', error); + alert('Error saving invoice'); + } +} + +async function editInvoice(id) { + await openInvoiceModal(id); +} + +async function deleteInvoice(id) { + if (!confirm('Are you sure you want to delete this invoice?')) return; + + try { + const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadInvoices(); + } else { + alert('Error deleting invoice'); + } + } catch (error) { + console.error('Error:', error); + alert('Error deleting invoice'); + } +} + +function viewInvoicePDF(id) { + window.open(`/api/invoices/${id}/pdf`, '_blank'); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1571047 --- /dev/null +++ b/public/index.html @@ -0,0 +1,384 @@ + + + + + + Quote & Invoice Management - Bay Area Affiliates + + + + + + +
+ + + + +
+ +
+
+

Quotes

+ +
+ +
+ + + + + + + + + + + + +
Quote #CustomerDateTotalActions
+
+
+ + + + + + + + + +
+
+ + + + + + + + + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..114feb9 --- /dev/null +++ b/server.js @@ -0,0 +1,1418 @@ +const express = require('express'); +const { Pool } = require('pg'); +const path = require('path'); +const puppeteer = require('puppeteer'); +const fs = require('fs').promises; +const multer = require('multer'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Database connection +const pool = new Pool({ + user: process.env.DB_USER || 'postgres', + host: process.env.DB_HOST || 'localhost', + database: process.env.DB_NAME || 'quotes_db', + password: process.env.DB_PASSWORD || 'postgres', + port: process.env.DB_PORT || 5432, +}); + +// Middleware +app.use(express.json()); +app.use(express.static('public')); + +// Configure multer for logo upload +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(__dirname, 'public', 'uploads'); + try { + await fs.mkdir(uploadDir, { recursive: true }); + } catch (err) { + console.error('Error creating upload directory:', err); + } + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + cb(null, 'company-logo.png'); + } +}); + +const upload = multer({ + storage: storage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed')); + } + } +}); + +// Helper functions +function formatDate(date) { + const d = new Date(date); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const year = d.getFullYear(); + return `${month}/${day}/${year}`; +} + +async function getNextQuoteNumber() { + const year = new Date().getFullYear(); + const result = await pool.query( + 'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1', + [`${year}-%`] + ); + + if (result.rows.length === 0) { + return `${year}-001`; + } + + const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]); + const nextNumber = String(lastNumber + 1).padStart(3, '0'); + return `${year}-${nextNumber}`; +} + +async function getNextInvoiceNumber() { + const year = new Date().getFullYear(); + const result = await pool.query( + 'SELECT invoice_number FROM invoices WHERE invoice_number LIKE $1 ORDER BY invoice_number DESC LIMIT 1', + [`${year}-%`] + ); + + if (result.rows.length === 0) { + return `${year}-001`; + } + + const lastNumber = parseInt(result.rows[0].invoice_number.split('-')[1]); + const nextNumber = String(lastNumber + 1).padStart(3, '0'); + return `${year}-${nextNumber}`; +} + +// Logo endpoints +app.get('/api/logo-info', async (req, res) => { + try { + const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png'); + try { + await fs.access(logoPath); + res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' }); + } catch { + res.json({ hasLogo: false }); + } + } catch (error) { + console.error('Error checking logo:', error); + res.status(500).json({ error: 'Error checking logo' }); + } +}); + +app.post('/api/upload-logo', upload.single('logo'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + res.json({ + message: 'Logo uploaded successfully', + path: '/uploads/company-logo.png' + }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Error uploading logo' }); + } +}); + +// Customer endpoints +app.get('/api/customers', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM customers ORDER BY name'); + res.json(result.rows); + } catch (error) { + console.error('Error fetching customers:', error); + res.status(500).json({ error: 'Error fetching customers' }); + } +}); + +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 (error) { + console.error('Error creating customer:', error); + res.status(500).json({ error: 'Error creating customer' }); + } +}); + +app.put('/api/customers/:id', async (req, res) => { + const { id } = req.params; + 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, id] + ); + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating customer:', error); + res.status(500).json({ error: 'Error updating customer' }); + } +}); + +app.delete('/api/customers/:id', async (req, res) => { + const { id } = req.params; + try { + await pool.query('DELETE FROM customers WHERE id = $1', [id]); + res.json({ success: true }); + } catch (error) { + console.error('Error deleting customer:', error); + res.status(500).json({ error: 'Error deleting customer' }); + } +}); + +// Quote endpoints +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.created_at DESC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching quotes:', error); + res.status(500).json({ error: 'Error fetching quotes' }); + } +}); + +app.get('/api/quotes/:id', async (req, res) => { + const { id } = req.params; + 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 + `, [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' }); + } +}); + +app.post('/api/quotes', 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(); + + // Calculate totals + 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; + + // 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) + 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; + + // Insert items + for (let i = 0; i < items.length; i++) { + await client.query( + 'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', + [quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] + ); + } + + 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(); + } +}); + +app.put('/api/quotes/: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'); + + // Calculate totals + 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; + + // Update quote + 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] + ); + + // Delete old items and insert new ones + 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) VALUES ($1, $2, $3, $4, $5, $6)', + [id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] + ); + } + + 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(); + } +}); + +app.delete('/api/quotes/: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(); + } +}); + +// Invoice endpoints +app.get('/api/invoices', async (req, res) => { + try { + const result = await pool.query(` + SELECT i.*, c.name as customer_name + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + ORDER BY i.created_at DESC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching invoices:', error); + res.status(500).json({ error: 'Error fetching invoices' }); + } +}); + +app.get('/api/invoices/:id', async (req, res) => { + const { id } = req.params; + try { + const invoiceResult = await pool.query(` + SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + WHERE i.id = $1 + `, [id]); + + if (invoiceResult.rows.length === 0) { + return res.status(404).json({ error: 'Invoice not found' }); + } + + const itemsResult = await pool.query( + 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', + [id] + ); + + res.json({ + invoice: invoiceResult.rows[0], + items: itemsResult.rows + }); + } catch (error) { + console.error('Error fetching invoice:', error); + res.status(500).json({ error: 'Error fetching invoice' }); + } +}); + +app.post('/api/invoices', async (req, res) => { + const { customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id } = req.body; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const invoice_number = await getNextInvoiceNumber(); + + // Calculate totals - invoices should NOT have TBD items + let subtotal = 0; + + for (const item of items) { + 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; + + // Insert invoice + 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, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id] + ); + + const invoiceId = invoiceResult.rows[0].id; + + // Insert items + for (let i = 0; i < items.length; i++) { + await client.query( + 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', + [invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] + ); + } + + await client.query('COMMIT'); + res.json(invoiceResult.rows[0]); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error creating invoice:', error); + res.status(500).json({ error: 'Error creating invoice' }); + } finally { + client.release(); + } +}); + +app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => { + const { id } = req.params; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Get quote details + 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 + `, [id]); + + if (quoteResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Quote not found' }); + } + + const quote = quoteResult.rows[0]; + + // Get quote items + const itemsResult = await pool.query( + 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', + [id] + ); + + // Check for TBD items + 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.' }); + } + + // Create invoice + const invoice_number = await getNextInvoiceNumber(); + 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; + + // Copy items to invoice + 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) VALUES ($1, $2, $3, $4, $5, $6)', + [invoiceId, item.quantity, item.description, item.rate, item.amount, i] + ); + } + + 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(); + } +}); + +app.put('/api/invoices/:id', async (req, res) => { + const { id } = req.params; + const { customer_id, invoice_date, terms, auth_code, tax_exempt, items } = req.body; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Calculate totals + let subtotal = 0; + + for (const item of items) { + 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; + + // Update invoice + await client.query( + `UPDATE invoices SET customer_id = $1, invoice_date = $2, terms = $3, auth_code = $4, tax_exempt = $5, + tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, updated_at = CURRENT_TIMESTAMP + WHERE id = $10`, + [customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id] + ); + + // Delete old items and insert new ones + await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); + + for (let i = 0; i < items.length; i++) { + await client.query( + 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', + [id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] + ); + } + + await client.query('COMMIT'); + res.json({ success: true }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error updating invoice:', error); + res.status(500).json({ error: 'Error updating invoice' }); + } finally { + client.release(); + } +}); + +app.delete('/api/invoices/:id', async (req, res) => { + const { id } = req.params; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); + await client.query('DELETE FROM invoices WHERE id = $1', [id]); + await client.query('COMMIT'); + res.json({ success: true }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error deleting invoice:', error); + res.status(500).json({ error: 'Error deleting invoice' }); + } finally { + client.release(); + } +}); + +// PDF Generation for Quotes +app.get('/api/quotes/:id/pdf', async (req, res) => { + const { id } = req.params; + + console.log(`[PDF] Starting quote PDF generation for ID: ${id}`); + + try { + console.log('[PDF] Fetching quote from database...'); + 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 + `, [id]); + + if (quoteResult.rows.length === 0) { + console.log('[PDF] Quote not found'); + return res.status(404).json({ error: 'Quote not found' }); + } + + const quote = quoteResult.rows[0]; + console.log(`[PDF] Quote loaded: ${quote.quote_number}`); + + console.log('[PDF] Fetching quote items...'); + const itemsResult = await pool.query( + 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', + [id] + ); + console.log(`[PDF] Items loaded: ${itemsResult.rows.length} items`); + + console.log('[PDF] Generating HTML...'); + const html = await generateQuotePDFHTML(quote, itemsResult.rows); + console.log(`[PDF] HTML generated, length: ${html.length} chars`); + + console.log('[PDF] Launching Puppeteer...'); + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--disable-software-rasterizer', + '--no-zygote', + '--single-process' // Wichtig für Docker! + ], + protocolTimeout: 180000, // 3 Minuten + timeout: 180000 + }); + console.log('[PDF] Browser launched successfully'); + + console.log('[PDF] Creating new page...'); + const page = await browser.newPage(); + console.log('[PDF] Page created'); + + console.log('[PDF] Setting content...'); + await page.setContent(html, { + waitUntil: 'networkidle0', + timeout: 60000 + }); + console.log('[PDF] Content set'); + + console.log('[PDF] Generating PDF...'); + const pdf = await page.pdf({ + format: 'Letter', + printBackground: true, + margin: { + top: '0.5in', + right: '0.5in', + bottom: '0.5in', + left: '0.5in' + }, + timeout: 60000 + }); + console.log(`[PDF] PDF generated, size: ${pdf.length} bytes`); + + console.log('[PDF] Closing browser...'); + await browser.close(); + console.log('[PDF] Browser closed'); + + 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] PDF sent to client successfully'); + } catch (error) { + console.error('[PDF] ERROR:', error); + console.error('[PDF] Stack:', error.stack); + res.status(500).json({ error: 'Error generating PDF', details: error.message }); + } +}); + +// PDF Generation for Invoices +app.get('/api/invoices/:id/pdf', async (req, res) => { + const { id } = req.params; + + console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`); + + try { + console.log('[INVOICE-PDF] Fetching invoice from database...'); + const invoiceResult = await pool.query(` + SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + WHERE i.id = $1 + `, [id]); + + if (invoiceResult.rows.length === 0) { + console.log('[INVOICE-PDF] Invoice not found'); + return res.status(404).json({ error: 'Invoice not found' }); + } + + const invoice = invoiceResult.rows[0]; + console.log(`[INVOICE-PDF] Invoice loaded: ${invoice.invoice_number}`); + + console.log('[INVOICE-PDF] Fetching invoice items...'); + const itemsResult = await pool.query( + 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', + [id] + ); + console.log(`[INVOICE-PDF] Items loaded: ${itemsResult.rows.length} items`); + + console.log('[INVOICE-PDF] Generating HTML...'); + const html = await generateInvoicePDFHTML(invoice, itemsResult.rows); + console.log(`[INVOICE-PDF] HTML generated, length: ${html.length} chars`); + + console.log('[INVOICE-PDF] Launching Puppeteer...'); + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--disable-software-rasterizer', + '--no-zygote', + '--single-process' + ], + protocolTimeout: 180000, + timeout: 180000 + }); + console.log('[INVOICE-PDF] Browser launched successfully'); + + console.log('[INVOICE-PDF] Creating new page...'); + const page = await browser.newPage(); + console.log('[INVOICE-PDF] Page created'); + + console.log('[INVOICE-PDF] Setting content...'); + await page.setContent(html, { + waitUntil: 'networkidle0', + timeout: 60000 + }); + console.log('[INVOICE-PDF] Content set'); + + console.log('[INVOICE-PDF] Generating PDF...'); + const pdf = await page.pdf({ + format: 'Letter', + printBackground: true, + margin: { + top: '0.5in', + right: '0.5in', + bottom: '0.5in', + left: '0.5in' + }, + timeout: 60000 + }); + console.log(`[INVOICE-PDF] PDF generated, size: ${pdf.length} bytes`); + + console.log('[INVOICE-PDF] Closing browser...'); + await browser.close(); + console.log('[INVOICE-PDF] Browser closed'); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdf.length, + 'Content-Disposition': `attachment; filename="Invoice-${invoice.invoice_number}.pdf"` + }); + res.end(pdf, 'binary'); + console.log('[INVOICE-PDF] PDF sent to client successfully'); + + } catch (error) { + console.error('[INVOICE-PDF] ERROR:', error); + console.error('[INVOICE-PDF] Stack:', error.stack); + res.status(500).json({ error: 'Error generating PDF', details: error.message }); + } +}); + +async function generateQuotePDFHTML(quote, items) { + // Check if logo exists + const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png'); + let logoHTML = ''; + try { + const logoData = await fs.readFile(logoPath); + const logoBase64 = logoData.toString('base64'); + logoHTML = ``; + } catch (error) { + // No logo, continue without it + } + + // Generate items HTML + let itemsHTML = items.map(item => ` + + ${item.quantity} + ${item.description} + ${item.rate} + ${item.amount} + + `).join(''); + + // Add totals + itemsHTML += ` + + Subtotal: + $${parseFloat(quote.subtotal).toFixed(2)} + `; + + if (!quote.tax_exempt) { + itemsHTML += ` + + Tax (${quote.tax_rate}%): + $${parseFloat(quote.tax_amount).toFixed(2)} + `; + } + + itemsHTML += ` + + TOTAL: + $${parseFloat(quote.total).toFixed(2)} + + + Thank you for your business! + `; + + // TBD note if applicable + let tbdNote = ''; + if (quote.has_tbd) { + tbdNote = '

* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.

'; + } + + return ` + + + + + + +
+
+
+ ${logoHTML} +
+

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} + +
QTYDESCRIPTIONRATEAMOUNT
+ ${tbdNote} +
+ +`; +} + +async function generateInvoicePDFHTML(invoice, items) { + // Check if logo exists + const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png'); + let logoHTML = ''; + try { + const logoData = await fs.readFile(logoPath); + const logoBase64 = logoData.toString('base64'); + logoHTML = ``; + } catch (error) { + // No logo, continue without it + } + + // Generate items HTML + let itemsHTML = items.map(item => ` + + ${item.quantity} + ${item.description} + ${item.rate} + ${item.amount} + + `).join(''); + + // Add totals + itemsHTML += ` + + Subtotal: + $${parseFloat(invoice.subtotal).toFixed(2)} + `; + + if (!invoice.tax_exempt) { + itemsHTML += ` + + Tax (${invoice.tax_rate}%): + $${parseFloat(invoice.tax_amount).toFixed(2)} + `; + } + + itemsHTML += ` + + TOTAL: + $${parseFloat(invoice.total).toFixed(2)} + + + Thank you for your business! + `; + + return ` + + + + + + +
+
+
+ ${logoHTML} +
+

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:
+ accounting@bayarea-cc.com +
+
+
+ +
+
+
Bill To:
+
+ ${invoice.customer_name}
+ ${invoice.street}
+ ${invoice.city}, ${invoice.state} ${invoice.zip_code} +
+
+ + + + + + + + + + + + + + + + + +
INVOICE #ACCOUNT NO.DATETERMS
${invoice.invoice_number}${invoice.account_number || ''}${formatDate(invoice.invoice_date)}${invoice.terms}
+
+ ${invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''} + + + + + + + + + + + + ${itemsHTML} + +
QTYDESCRIPTIONRATEAMOUNT
+
+ +`; +} + +// 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); +}); diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..91621cb --- /dev/null +++ b/setup.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +echo "============================================" +echo "Quote & Invoice System Setup" +echo "Bay Area Affiliates, Inc." +echo "============================================" +echo "" + +# Check if PostgreSQL is installed +if ! command -v psql &> /dev/null; then + echo "❌ PostgreSQL is not installed. Please install PostgreSQL first." + exit 1 +fi + +echo "✓ PostgreSQL found" +echo "" + +# Get database credentials +read -p "Enter PostgreSQL database name [quotes_db]: " DB_NAME +DB_NAME=${DB_NAME:-quotes_db} + +read -p "Enter PostgreSQL username [quoteuser]: " DB_USER +DB_USER=${DB_USER:-quoteuser} + +read -sp "Enter PostgreSQL password: " DB_PASSWORD +echo "" + +read -p "Enter PostgreSQL host [localhost]: " DB_HOST +DB_HOST=${DB_HOST:-localhost} + +read -p "Enter PostgreSQL port [5432]: " DB_PORT +DB_PORT=${DB_PORT:-5432} + +read -p "Enter application port [3000]: " APP_PORT +APP_PORT=${APP_PORT:-3000} + +echo "" +echo "Creating database and user..." + +# Create database (as postgres user) +sudo -u postgres psql -c "CREATE DATABASE $DB_NAME;" 2>/dev/null +sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';" 2>/dev/null +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" 2>/dev/null +sudo -u postgres psql -d $DB_NAME -c "GRANT ALL ON SCHEMA public TO $DB_USER;" 2>/dev/null + +echo "✓ Database setup complete" +echo "" + +# Create .env file +echo "Creating .env file..." +cat > .env << EOF +DB_HOST=$DB_HOST +DB_PORT=$DB_PORT +DB_USER=$DB_USER +DB_PASSWORD=$DB_PASSWORD +DB_NAME=$DB_NAME +PORT=$APP_PORT +NODE_ENV=production +EOF + +echo "✓ .env file created" +echo "" + +# Run database migrations +echo "Running database migrations..." +PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f init.sql +PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f add_invoices.sql + +echo "✓ Database tables created" +echo "" + +# Install Node.js dependencies +echo "Installing Node.js dependencies..." +npm install + +echo "✓ Dependencies installed" +echo "" + +# Create uploads directory +mkdir -p public/uploads + +echo "✓ Upload directory created" +echo "" + +echo "============================================" +echo "Setup Complete! 🎉" +echo "============================================" +echo "" +echo "To start the server:" +echo " npm start" +echo "" +echo "To start in development mode:" +echo " npm run dev" +echo "" +echo "Access the application at:" +echo " http://localhost:$APP_PORT" +echo "" +echo "============================================"