This commit is contained in:
Andreas Knuth 2026-02-01 16:02:18 -06:00
commit 73e81442cc
17 changed files with 3952 additions and 0 deletions

15
.dockerignore Normal file
View File

@ -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

10
.env.example Normal file
View File

@ -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

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
*.png

212
CHANGELOG.md Normal file
View File

@ -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.

41
Dockerfile Normal file
View File

@ -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"]

294
IMPLEMENTATION_SUMMARY.md Normal file
View File

@ -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": "<p>Email Hosting - Monthly</p>",
"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!** 🚀

264
INSTALLATION.md Normal file
View File

@ -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.

237
README.md Normal file
View File

@ -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.

38
add_invoices.sql Normal file
View File

@ -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);

42
docker-compose.yml Normal file
View File

@ -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:

53
init.sql Normal file
View File

@ -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;

19
package.json Normal file
View File

@ -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"
}
}

0
public/.gitkeep Normal file
View File

825
public/app.js Normal file
View File

@ -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 = '<p class="text-blue-600">Uploading...</p>';
try {
const response = await fetch('/api/upload-logo', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
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 = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
}
} catch (error) {
console.error('Upload error:', error);
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
}
}
// 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 => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
<td class="px-6 py-4 text-sm text-gray-500">${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`).join('');
}
function updateCustomerDropdown() {
const quoteSelect = document.getElementById('quote-customer');
const invoiceSelect = document.getElementById('invoice-customer');
const options = '<option value="">Select Customer...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).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 `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="viewQuotePDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="convertQuoteToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900"> Invoice</button>
<button onclick="editQuote(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteQuote(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`;
}).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 = `
<div class="col-span-1">
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
<input type="text" data-item="${itemId}" data-field="quantity"
value="${item ? item.quantity : ''}"
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-5">
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
<div data-item="${itemId}" data-field="description"
class="quote-item-description-editor border border-gray-300 rounded-md bg-white"
style="min-height: 60px;">
</div>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
<input type="text" data-item="${itemId}" data-field="rate"
value="${item ? item.rate : ''}"
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
<input type="text" data-item="${itemId}" data-field="amount"
value="${item ? item.amount : ''}"
class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-1 flex items-end">
<button type="button" onclick="removeQuoteItem(${itemId})"
class="w-full px-2 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
×
</button>
</div>
`;
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 => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="viewInvoicePDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="editInvoice(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteInvoice(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`).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 = `
<div class="col-span-1">
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
<input type="text" data-item="${itemId}" data-field="quantity"
value="${item ? item.quantity : ''}"
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-5">
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
<div data-item="${itemId}" data-field="description"
class="invoice-item-description-editor border border-gray-300 rounded-md bg-white"
style="min-height: 60px;">
</div>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
<input type="text" data-item="${itemId}" data-field="rate"
value="${item ? item.rate : ''}"
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
<input type="text" data-item="${itemId}" data-field="amount"
value="${item ? item.amount : ''}"
class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-1 flex items-end">
<button type="button" onclick="removeInvoiceItem(${itemId})"
class="w-full px-2 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
×
</button>
</div>
`;
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');
}

384
public/index.html Normal file
View File

@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quote & Invoice Management - Bay Area Affiliates</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
<style>
.modal {
display: none;
}
.modal.active {
display: flex;
}
</style>
</head>
<body class="bg-gray-100">
<div class="min-h-screen">
<!-- Navigation -->
<nav class="bg-blue-900 text-white shadow-lg">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Bay Area Affiliates, Inc.</h1>
<p class="text-sm text-blue-200">Quote & Invoice Management System</p>
</div>
<div class="flex space-x-4">
<button onclick="showTab('quotes')" id="tab-quotes" class="px-4 py-2 rounded bg-blue-800 tab-btn">Quotes</button>
<button onclick="showTab('invoices')" id="tab-invoices" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Invoices</button>
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</button>
<button onclick="showTab('settings')" id="tab-settings" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Settings</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
<!-- Quotes Tab -->
<div id="quotes-tab" class="tab-content">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800">Quotes</h2>
<button onclick="openQuoteModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
+ New Quote
</button>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Quote #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="quotes-list" class="bg-white divide-y divide-gray-200">
</tbody>
</table>
</div>
</div>
<!-- Invoices Tab -->
<div id="invoices-tab" class="tab-content hidden">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
<button onclick="openInvoiceModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
+ New Invoice
</button>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
</tbody>
</table>
</div>
</div>
<!-- Customers Tab -->
<div id="customers-tab" class="tab-content hidden">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800">Customers</h2>
<button onclick="openCustomerModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
+ New Customer
</button>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="customers-list" class="bg-white divide-y divide-gray-200">
</tbody>
</table>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-content hidden">
<div class="mb-6">
<h2 class="text-3xl font-bold text-gray-800">Settings</h2>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold mb-4">Company Logo</h3>
<p class="text-gray-600 mb-4">Upload your company logo to appear on quotes and invoices. Recommended size: 200x200px (PNG or JPG)</p>
<div class="mb-4">
<div id="logo-preview" class="mb-4 hidden">
<p class="text-sm text-gray-600 mb-2">Current Logo:</p>
<img id="logo-image" src="" alt="Company Logo" class="h-20 border border-gray-300 rounded">
</div>
<input type="file" id="logo-upload" accept="image/png,image/jpeg,image/jpg,image/gif" class="hidden">
<button onclick="document.getElementById('logo-upload').click()"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
Choose Logo
</button>
<span id="logo-filename" class="ml-4 text-gray-600"></span>
</div>
<button onclick="uploadLogo()" id="upload-btn" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg disabled:bg-gray-400" disabled>
Upload Logo
</button>
<div id="upload-status" class="mt-4"></div>
</div>
</div>
</div>
</div>
<!-- Customer Modal -->
<div id="customer-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center">
<div class="relative mx-auto p-8 border w-full max-w-2xl shadow-lg rounded-lg bg-white">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900" id="customer-modal-title">New Customer</h3>
<button onclick="closeCustomerModal()" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="customer-form" class="space-y-4">
<input type="hidden" id="customer-id">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input type="text" id="customer-name" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address</label>
<input type="text" id="customer-street" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" id="customer-city" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
<input type="text" id="customer-state" required maxlength="2" placeholder="TX"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
<input type="text" id="customer-zip" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Account Number</label>
<input type="text" id="customer-account"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeCustomerModal()"
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Save Customer
</button>
</div>
</form>
</div>
</div>
<!-- Quote Modal -->
<div id="quote-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center z-50">
<div class="relative mx-auto p-8 border w-full max-w-6xl shadow-lg rounded-lg bg-white my-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900" id="quote-modal-title">New Quote</h3>
<button onclick="closeQuoteModal()" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="quote-form" class="space-y-6">
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
<select id="quote-customer" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<option value="">Select Customer...</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input type="date" id="quote-date" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex items-center pt-6">
<input type="checkbox" id="quote-tax-exempt"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="quote-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
</div>
</div>
<div>
<div class="flex justify-between items-center mb-3">
<label class="block text-sm font-medium text-gray-700">Items</label>
<button type="button" onclick="addQuoteItem()"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
+ Add Item
</button>
</div>
<div id="quote-items"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="space-y-2 text-right">
<div class="flex justify-end items-center">
<span class="text-sm font-medium text-gray-700 mr-4">Subtotal:</span>
<span id="quote-subtotal" class="text-lg font-semibold">$0.00</span>
</div>
<div id="quote-tax-row" class="flex justify-end items-center">
<span class="text-sm font-medium text-gray-700 mr-4">Tax (8.25%):</span>
<span id="quote-tax" class="text-lg font-semibold">$0.00</span>
</div>
<div class="flex justify-end items-center pt-2 border-t border-gray-300">
<span class="text-lg font-bold text-gray-900 mr-4">TOTAL:</span>
<span id="quote-total" class="text-2xl font-bold text-blue-600">$0.00</span>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeQuoteModal()"
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Save Quote
</button>
</div>
</form>
</div>
</div>
<!-- Invoice Modal -->
<div id="invoice-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center z-50">
<div class="relative mx-auto p-8 border w-full max-w-6xl shadow-lg rounded-lg bg-white my-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900" id="invoice-modal-title">New Invoice</h3>
<button onclick="closeInvoiceModal()" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="invoice-form" class="space-y-6">
<div class="grid grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
<select id="invoice-customer" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<option value="">Select Customer...</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input type="date" id="invoice-date" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
<input type="text" id="invoice-terms" value="Net 30" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex items-center pt-6">
<input type="checkbox" id="invoice-tax-exempt"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Authorization (optional)</label>
<input type="text" id="invoice-authorization"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="P.O. Number, Authorization Code, etc.">
</div>
<div>
<div class="flex justify-between items-center mb-3">
<label class="block text-sm font-medium text-gray-700">Items</label>
<button type="button" onclick="addInvoiceItem()"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
+ Add Item
</button>
</div>
<div id="invoice-items"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="space-y-2 text-right">
<div class="flex justify-end items-center">
<span class="text-sm font-medium text-gray-700 mr-4">Subtotal:</span>
<span id="invoice-subtotal" class="text-lg font-semibold">$0.00</span>
</div>
<div id="invoice-tax-row" class="flex justify-end items-center">
<span class="text-sm font-medium text-gray-700 mr-4">Tax (8.25%):</span>
<span id="invoice-tax" class="text-lg font-semibold">$0.00</span>
</div>
<div class="flex justify-end items-center pt-2 border-t border-gray-300">
<span class="text-lg font-bold text-gray-900 mr-4">TOTAL:</span>
<span id="invoice-total" class="text-2xl font-bold text-blue-600">$0.00</span>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeInvoiceModal()"
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Save Invoice
</button>
</div>
</form>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

1418
server.js Normal file

File diff suppressed because it is too large Load Diff

98
setup.sh Executable file
View File

@ -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 "============================================"