init
This commit is contained in:
commit
73e81442cc
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
.env
|
||||||
|
*.png
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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!** 🚀
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,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');
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 "============================================"
|
||||||
Loading…
Reference in New Issue