init
This commit is contained in:
commit
bf1b7dc0f9
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.DS_Store
|
||||
*.md
|
||||
uploads/*.png
|
||||
uploads/*.jpg
|
||||
uploads/*.jpeg
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
npm-debug.log
|
||||
.env
|
||||
.DS_Store
|
||||
uploads/
|
||||
*.log
|
||||
postgres_data/
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
# Install Chromium and dependencies
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont
|
||||
|
||||
# Set Puppeteer to use installed Chromium
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev --loglevel=verbose
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# Schnellstart-Anleitung
|
||||
|
||||
## Installation in 3 Schritten
|
||||
|
||||
### Schritt 1: Voraussetzungen prüfen
|
||||
- Docker Desktop installiert und gestartet
|
||||
- Port 3000 ist verfügbar
|
||||
|
||||
### Schritt 2: Anwendung starten
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
Doppelklick auf `start.bat`
|
||||
|
||||
**Manuell mit Docker Compose:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Schritt 3: Anwendung öffnen
|
||||
Öffnen Sie im Browser: http://localhost:3000
|
||||
|
||||
## Erste Schritte in der Anwendung
|
||||
|
||||
### 1. Kunden anlegen
|
||||
- Klicken Sie auf "Customers" in der Navigation
|
||||
- Klicken Sie auf "+ New Customer"
|
||||
- Füllen Sie die Formulardaten aus:
|
||||
- Company Name: z.B. "ABC Corporation"
|
||||
- Street Address: z.B. "123 Main Street"
|
||||
- City: z.B. "Corpus Christi"
|
||||
- State: z.B. "TX"
|
||||
- Zip Code: z.B. "78401"
|
||||
- Account Number: Optional, z.B. "ACC-12345"
|
||||
- Klicken Sie auf "Save Customer"
|
||||
|
||||
### 2. Erstes Quote erstellen
|
||||
- Klicken Sie auf "Quotes" in der Navigation
|
||||
- Klicken Sie auf "+ New Quote"
|
||||
- Wählen Sie einen Kunden aus dem Dropdown
|
||||
- Die Quote-Nummer wird automatisch generiert (z.B. 2026-01-0001)
|
||||
- Das Datum ist vorausgefüllt, kann aber geändert werden
|
||||
- Klicken Sie auf "+ Add Item" um Positionen hinzuzufügen:
|
||||
- Quantity: z.B. "1" oder "2" oder "TBD"
|
||||
- Description: Beschreibung des Produkts/Service
|
||||
- Rate: Preis pro Einheit, z.B. "100.00" oder "150.00/hr"
|
||||
- Amount: Gesamtbetrag, z.B. "100.00"
|
||||
- TBD: Setzen Sie das Häkchen für "To Be Determined" Items
|
||||
- Bei Tax-Exempt Kunden (Churches, Non-Profits):
|
||||
- Aktivieren Sie "Tax Exempt"
|
||||
- Bei TBD Items:
|
||||
- Geben Sie eine Fußnote ein, z.B. "Total excludes labor charges..."
|
||||
- Klicken Sie auf "Save Quote"
|
||||
|
||||
### 3. PDF generieren
|
||||
- In der Quote-Liste klicken Sie auf "PDF" beim gewünschten Quote
|
||||
- Das PDF wird automatisch heruntergeladen
|
||||
|
||||
## Beispiel-Workflow
|
||||
|
||||
1. **Kunde "Braselton Development" ist bereits angelegt** (aus init.sql)
|
||||
2. **Neues Quote erstellen:**
|
||||
- Customer: Braselton Development
|
||||
- Quote #: 2026-01-0001 (automatisch)
|
||||
- Date: Heute (automatisch)
|
||||
- Item 1:
|
||||
- Qty: 1
|
||||
- Description: Lenovo Yoga Laptop Setup
|
||||
- Rate: 2,890.00
|
||||
- Amount: 2,890.00
|
||||
- Item 2:
|
||||
- Qty: TBD
|
||||
- Description: Labor for installation
|
||||
- Rate: 125.00/hr
|
||||
- Amount: TBD (Checkbox aktivieren)
|
||||
- TBD Note: "Labor charges will be determined based on actual time"
|
||||
- Tax: 8.25% (automatisch berechnet, außer Tax Exempt ist aktiviert)
|
||||
3. **Quote speichern**
|
||||
4. **PDF herunterladen** → Professionelles Dokument im Corporate Design
|
||||
|
||||
## Nützliche Befehle
|
||||
|
||||
### Logs ansehen
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Anwendung stoppen
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Anwendung neu starten
|
||||
```bash
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### Datenbank-Backup erstellen
|
||||
```bash
|
||||
docker exec quote_postgres pg_dump -U quoteuser quotedb > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Tipps & Tricks
|
||||
|
||||
1. **Quote-Nummern:** Werden automatisch monatlich hochgezählt
|
||||
- Januar 2026: 2026-01-0001, 2026-01-0002, ...
|
||||
- Februar 2026: 2026-02-0001, 2026-02-0002, ...
|
||||
|
||||
2. **TBD Items:** Ideal für:
|
||||
- Stundenbasierte Arbeit
|
||||
- Noch nicht feststehende Mengen
|
||||
- Variable Kosten
|
||||
|
||||
3. **Tax Exempt:** Verwenden Sie diese Option für:
|
||||
- Kirchen (Churches)
|
||||
- Non-Profit Organisationen
|
||||
- Gemeinnützige Vereine
|
||||
|
||||
4. **Bearbeitung:** Alle Quotes können nachträglich bearbeitet werden
|
||||
- Klicken Sie einfach auf "Edit"
|
||||
- Ändern Sie die Daten
|
||||
- Speichern Sie erneut
|
||||
|
||||
5. **PDF-Qualität:** Die PDFs entsprechen exakt Ihrer HTML-Vorlage
|
||||
- Professionelles Layout
|
||||
- Druckfertig
|
||||
- Letterformat (8.5" x 11")
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Port 3000 bereits belegt?
|
||||
Ändern Sie in `docker-compose.yml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "3001:3000" # Statt 3000:3000
|
||||
```
|
||||
Dann öffnen Sie: http://localhost:3001
|
||||
|
||||
### Datenbank-Fehler?
|
||||
```bash
|
||||
docker-compose down -v # VORSICHT: Löscht alle Daten!
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Anwendung lädt nicht?
|
||||
```bash
|
||||
# Prüfen Sie die Logs
|
||||
docker-compose logs app
|
||||
|
||||
# Prüfen Sie ob Container laufen
|
||||
docker ps
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Bei weiteren Fragen schauen Sie in die ausführliche README.md Datei.
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
# Quote Management System - Bay Area Affiliates, Inc.
|
||||
|
||||
Ein vollständiges Quote-Management-System mit PostgreSQL-Datenbank, Node.js Backend und Tailwind CSS Frontend.
|
||||
|
||||
## Features
|
||||
|
||||
- **Kundenverwaltung**: Erstellen, Bearbeiten und Löschen von Kunden mit vollständigen US-Adressen
|
||||
- **Quote-Erstellung**: Generierung professioneller Angebote mit automatischer Nummerierung (YYYY-MM-XXXX)
|
||||
- **Flexible Preisgestaltung**:
|
||||
- Automatische Berechnung von Subtotal, Tax (8.25%) und Total
|
||||
- Tax-Exempt Option für Churches und Non-Profits
|
||||
- TBD-Support für noch nicht festgelegte Beträge mit flexiblen Fußnoten
|
||||
- **PDF-Export**: Generierung druckfertiger PDFs basierend auf Ihrer HTML-Vorlage
|
||||
- **Responsive Design**: Modernes UI mit Tailwind CSS
|
||||
|
||||
## Installation & Start
|
||||
|
||||
### Voraussetzungen
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
### Schnellstart
|
||||
|
||||
1. **Repository klonen oder Dateien kopieren**
|
||||
```bash
|
||||
cd quote-system
|
||||
```
|
||||
|
||||
2. **Logo hochladen (optional)**
|
||||
- Legen Sie Ihr Logo als `logo_.png` in den Ordner `/uploads` oder nutzen Sie die Upload-Funktion in der App
|
||||
|
||||
3. **Anwendung starten**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Anwendung öffnen**
|
||||
- Browser: http://localhost:3000
|
||||
|
||||
### Erste Schritte
|
||||
|
||||
1. Die Datenbank wird automatisch initialisiert mit einem Beispielkunden (Braselton Development)
|
||||
2. Navigieren Sie zu "Customers" um weitere Kunden anzulegen
|
||||
3. Erstellen Sie Ihr erstes Quote unter "Quotes" → "+ New Quote"
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Kunden verwalten
|
||||
|
||||
- **Neuen Kunden anlegen**: Klicken Sie auf "+ New Customer"
|
||||
- **Kunde bearbeiten**: Klicken Sie auf "Edit" neben dem Kunden
|
||||
- **Kunde löschen**: Klicken Sie auf "Delete" (Achtung: Löscht auch alle zugehörigen Quotes!)
|
||||
|
||||
### Quotes erstellen
|
||||
|
||||
1. Klicken Sie auf "+ New Quote"
|
||||
2. Wählen Sie einen Kunden aus dem Dropdown
|
||||
3. Die Quote-Nummer wird automatisch generiert (Format: YYYY-MM-XXXX)
|
||||
4. Das Datum ist standardmäßig heute, kann aber geändert werden
|
||||
5. Fügen Sie Line Items hinzu:
|
||||
- **Quantity**: Menge (z.B. "1", "2", "TBD")
|
||||
- **Description**: Beschreibung des Artikels/Service
|
||||
- **Rate**: Preis pro Einheit oder Stundensatz
|
||||
- **Amount**: Gesamtbetrag (wird bei TBD automatisch auf "TBD" gesetzt)
|
||||
- **TBD Checkbox**: Markiert Posten als "To Be Determined"
|
||||
6. Für Tax-Exempt Kunden (Churches, Non-Profits): Aktivieren Sie "Tax Exempt"
|
||||
7. Bei TBD-Posten: Geben Sie eine Fußnote ein (z.B. "Total excludes labor charges...")
|
||||
|
||||
### PDF generieren
|
||||
|
||||
- Klicken Sie auf "PDF" neben dem gewünschten Quote
|
||||
- Das PDF wird automatisch im originalen Design heruntergeladen
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Architektur
|
||||
|
||||
- **Backend**: Node.js mit Express
|
||||
- **Datenbank**: PostgreSQL 15
|
||||
- **Frontend**: Vanilla JavaScript mit Tailwind CSS
|
||||
- **PDF Generation**: Puppeteer
|
||||
- **Container**: Docker & Docker Compose
|
||||
|
||||
### Datenbank-Schema
|
||||
|
||||
#### Customers
|
||||
- id, name, street, city, state, zip_code, account_number
|
||||
- created_at, updated_at
|
||||
|
||||
#### Quotes
|
||||
- id, quote_number, customer_id (FK), quote_date
|
||||
- tax_exempt, tax_rate, subtotal, tax_amount, total
|
||||
- has_tbd, tbd_note
|
||||
- created_at, updated_at
|
||||
|
||||
#### Quote Items
|
||||
- id, quote_id (FK), quantity, description, rate, amount
|
||||
- is_tbd, item_order
|
||||
- created_at
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Customers
|
||||
- `GET /api/customers` - Alle Kunden
|
||||
- `GET /api/customers/:id` - Einzelner Kunde
|
||||
- `POST /api/customers` - Neuer Kunde
|
||||
- `PUT /api/customers/:id` - Kunde aktualisieren
|
||||
- `DELETE /api/customers/:id` - Kunde löschen
|
||||
|
||||
#### Quotes
|
||||
- `GET /api/quotes` - Alle Quotes
|
||||
- `GET /api/quotes/:id` - Einzelnes Quote mit Items
|
||||
- `POST /api/quotes` - Neues Quote
|
||||
- `PUT /api/quotes/:id` - Quote aktualisieren
|
||||
- `DELETE /api/quotes/:id` - Quote löschen
|
||||
- `GET /api/quotes/next-number` - Nächste Quote-Nummer
|
||||
- `POST /api/quotes/:id/pdf` - PDF generieren
|
||||
|
||||
#### Upload
|
||||
- `POST /api/upload-logo` - Logo hochladen
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Umgebungsvariablen
|
||||
|
||||
Die folgenden Umgebungsvariablen können in der `docker-compose.yml` angepasst werden:
|
||||
|
||||
```yaml
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: quoteuser
|
||||
DB_PASSWORD: quotepass123 # ÄNDERN SIE DIES FÜR PRODUKTION!
|
||||
DB_NAME: quotedb
|
||||
```
|
||||
|
||||
### Ports
|
||||
|
||||
- **Anwendung**: 3000
|
||||
- **PostgreSQL**: 5432 (extern erreichbar für Backups)
|
||||
|
||||
## Backup & Restore
|
||||
|
||||
### Backup erstellen
|
||||
```bash
|
||||
docker exec quote_postgres pg_dump -U quoteuser quotedb > backup.sql
|
||||
```
|
||||
|
||||
### Backup wiederherstellen
|
||||
```bash
|
||||
docker exec -i quote_postgres psql -U quoteuser quotedb < backup.sql
|
||||
```
|
||||
|
||||
## Wartung
|
||||
|
||||
### Logs ansehen
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Anwendung neu starten
|
||||
```bash
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### Anwendung stoppen
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Datenbank zurücksetzen (VORSICHT!)
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Anpassungen
|
||||
|
||||
### Firmendaten ändern
|
||||
Bearbeiten Sie die HTML-Vorlage in `server.js` in der Funktion `generateQuoteHTML()`:
|
||||
- Firmenname
|
||||
- Adresse
|
||||
- Telefonnummern
|
||||
- E-Mail
|
||||
- Tagline
|
||||
|
||||
### Tax Rate ändern
|
||||
Standard ist 8.25% (Texas). Ändern Sie in:
|
||||
- `server.js`: Zeile mit `tax_rate: 8.25`
|
||||
- `init.sql`: Zeile mit `tax_rate DECIMAL(5,2) DEFAULT 8.25`
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Anwendung startet nicht
|
||||
```bash
|
||||
docker-compose logs app
|
||||
```
|
||||
|
||||
### Datenbankverbindung fehlgeschlagen
|
||||
```bash
|
||||
docker-compose logs postgres
|
||||
```
|
||||
|
||||
### Port bereits belegt
|
||||
Ändern Sie in `docker-compose.yml` den Port:
|
||||
```yaml
|
||||
ports:
|
||||
- "3001:3000" # Statt 3000:3000
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Bei Problemen oder Fragen wenden Sie sich an Ihren Administrator.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Proprietär - Bay Area Affiliates, Inc.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: quote_postgres
|
||||
environment:
|
||||
POSTGRES_USER: quoteuser
|
||||
POSTGRES_PASSWORD: quotepass123
|
||||
POSTGRES_DB: quotedb
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U quoteuser"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
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: quotedb
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
-- 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,
|
||||
tbd_note TEXT,
|
||||
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,
|
||||
is_tbd BOOLEAN DEFAULT FALSE,
|
||||
item_order INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_quotes_quote_number ON quotes(quote_number);
|
||||
CREATE INDEX idx_quotes_customer_id ON quotes(customer_id);
|
||||
CREATE INDEX 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');
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "quote-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Quote 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-core": "^23.11.1",
|
||||
"chromium": "^3.0.3",
|
||||
"cors": "^2.8.5"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
// Global state
|
||||
let customers = [];
|
||||
let quotes = [];
|
||||
let currentQuoteId = null;
|
||||
let currentCustomerId = null;
|
||||
let itemCounter = 0;
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCustomers();
|
||||
loadQuotes();
|
||||
setDefaultDate();
|
||||
|
||||
// Setup form handlers
|
||||
document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit);
|
||||
document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
|
||||
document.getElementById('quote-tax-exempt').addEventListener('change', updateTotals);
|
||||
});
|
||||
|
||||
// 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 === 'customers') {
|
||||
loadCustomers();
|
||||
}
|
||||
}
|
||||
|
||||
// Customers
|
||||
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 select = document.getElementById('quote-customer');
|
||||
select.innerHTML = '<option value="">Select Customer...</option>' +
|
||||
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Quotes
|
||||
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="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 quote = await response.json();
|
||||
|
||||
document.getElementById('quote-id').value = quote.id;
|
||||
document.getElementById('quote-customer').value = quote.customer_id;
|
||||
document.getElementById('quote-number').value = quote.quote_number;
|
||||
document.getElementById('quote-date').value = quote.quote_date;
|
||||
document.getElementById('quote-tax-exempt').checked = quote.tax_exempt;
|
||||
document.getElementById('quote-tbd-note').value = quote.tbd_note || '';
|
||||
|
||||
// Load items
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
itemCounter = 0;
|
||||
quote.items.forEach(item => {
|
||||
addQuoteItem(item);
|
||||
});
|
||||
|
||||
updateTotals();
|
||||
} else {
|
||||
title.textContent = 'New Quote';
|
||||
document.getElementById('quote-form').reset();
|
||||
document.getElementById('quote-id').value = '';
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
itemCounter = 0;
|
||||
setDefaultDate();
|
||||
|
||||
// Get next quote number
|
||||
const response = await fetch('/api/quotes/next-number');
|
||||
const data = await response.json();
|
||||
document.getElementById('quote-number').value = data.quote_number;
|
||||
|
||||
// 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';
|
||||
itemDiv.id = `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="item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="col-span-5">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea data-item="${itemId}" data-field="description" rows="2"
|
||||
class="item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">${item ? item.description : ''}</textarea>
|
||||
</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="item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</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="item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">TBD</label>
|
||||
<input type="checkbox" data-item="${itemId}" data-field="is_tbd"
|
||||
${item && item.is_tbd ? 'checked' : ''}
|
||||
class="item-tbd h-5 w-5 mt-2 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
</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);
|
||||
|
||||
// Add event listeners
|
||||
itemDiv.querySelectorAll('.item-input, .item-amount').forEach(input => {
|
||||
input.addEventListener('input', updateTotals);
|
||||
});
|
||||
|
||||
itemDiv.querySelector('.item-tbd').addEventListener('change', function() {
|
||||
const amountInput = itemDiv.querySelector('.item-amount');
|
||||
if (this.checked) {
|
||||
amountInput.value = 'TBD';
|
||||
amountInput.readOnly = true;
|
||||
amountInput.classList.add('bg-gray-100');
|
||||
} else {
|
||||
if (amountInput.value === 'TBD') {
|
||||
amountInput.value = '';
|
||||
}
|
||||
amountInput.readOnly = false;
|
||||
amountInput.classList.remove('bg-gray-100');
|
||||
}
|
||||
updateTotals();
|
||||
});
|
||||
|
||||
// Trigger TBD state if loaded
|
||||
if (item && item.is_tbd) {
|
||||
itemDiv.querySelector('.item-tbd').dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
updateTotals();
|
||||
}
|
||||
|
||||
function removeQuoteItem(itemId) {
|
||||
document.getElementById(`item-${itemId}`).remove();
|
||||
updateTotals();
|
||||
}
|
||||
|
||||
function updateTotals() {
|
||||
const items = getQuoteItems();
|
||||
const taxExempt = document.getElementById('quote-tax-exempt').checked;
|
||||
|
||||
let subtotal = 0;
|
||||
let hasTbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.is_tbd || item.amount === 'TBD') {
|
||||
hasTbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const taxRate = taxExempt ? 0 : 8.25;
|
||||
const taxAmount = subtotal * taxRate / 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)}`;
|
||||
|
||||
// Show/hide tax row
|
||||
document.getElementById('tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
|
||||
// Show/hide TBD note section
|
||||
document.getElementById('tbd-note-section').classList.toggle('hidden', !hasTbd);
|
||||
}
|
||||
|
||||
function getQuoteItems() {
|
||||
const items = [];
|
||||
const itemDivs = document.querySelectorAll('#quote-items > div');
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const item = {
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
description: div.querySelector('[data-field="description"]').value,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value,
|
||||
is_tbd: div.querySelector('[data-field="is_tbd"]').checked
|
||||
};
|
||||
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,
|
||||
tbd_note: document.getElementById('quote-tbd-note').value
|
||||
};
|
||||
|
||||
try {
|
||||
const quoteId = document.getElementById('quote-id').value;
|
||||
const url = quoteId ? `/api/quotes/${quoteId}` : '/api/quotes';
|
||||
const method = quoteId ? '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');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewQuotePDF(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/quotes/${id}/pdf`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
// Get quote number for filename
|
||||
const quote = quotes.find(q => q.id === id);
|
||||
a.download = `Quote_${quote.quote_number}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
alert('Error generating PDF');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error generating PDF');
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function setDefaultDate() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('quote-date').value = today;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quote Management System - Bay Area Affiliates</title>
|
||||
<script src="https://cdn.tailwindcss.com"></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 Management System</p>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<button onclick="showTab('quotes')" id="tab-quotes" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Quotes</button>
|
||||
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</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>
|
||||
|
||||
<!-- Quotes List -->
|
||||
<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">
|
||||
<!-- Quotes will be loaded here -->
|
||||
</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>
|
||||
|
||||
<!-- Customers List -->
|
||||
<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">
|
||||
<!-- Customers will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</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 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
||||
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">
|
||||
<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">
|
||||
<input type="hidden" id="quote-id">
|
||||
|
||||
<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">Quote Number</label>
|
||||
<input type="text" id="quote-number" readonly
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md bg-gray-50">
|
||||
</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>
|
||||
|
||||
<div class="flex items-center">
|
||||
<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 (Church, Non-Profit, etc.)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Items Section -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="text-lg font-semibold text-gray-900">Line Items</h4>
|
||||
<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" class="space-y-3">
|
||||
<!-- Items will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBD Note -->
|
||||
<div id="tbd-note-section" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">TBD Footnote</label>
|
||||
<input type="text" id="quote-tbd-note"
|
||||
placeholder="e.g., Total excludes labor charges which will be determined..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="flex justify-end space-x-8 text-lg">
|
||||
<div class="text-right">
|
||||
<div class="text-gray-600">Subtotal:</div>
|
||||
<div class="text-gray-600" id="tax-row">Tax (8.25%):</div>
|
||||
<div class="font-bold text-xl">Total:</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div id="quote-subtotal">$0.00</div>
|
||||
<div id="quote-tax">$0.00</div>
|
||||
<div class="font-bold text-xl" id="quote-total">$0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button type="button" onclick="closeQuoteModal()"
|
||||
class="px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
||||
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>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,734 @@
|
|||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Database configuration
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'quoteuser',
|
||||
password: process.env.DB_PASSWORD || 'quotepass123',
|
||||
database: process.env.DB_NAME || 'quotedb'
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// Configure multer for logo upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = './uploads';
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, 'logo_' + Date.now() + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
const filetypes = /jpeg|jpg|png|gif/;
|
||||
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = filetypes.test(file.mimetype);
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
});
|
||||
|
||||
// Generate next quote number
|
||||
async function generateQuoteNumber() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const prefix = `${year}-${month}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT quote_number FROM quotes
|
||||
WHERE quote_number LIKE $1
|
||||
ORDER BY quote_number DESC
|
||||
LIMIT 1`,
|
||||
[prefix + '%']
|
||||
);
|
||||
|
||||
let nextNumber = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return prefix + String(nextNumber).padStart(4, '0');
|
||||
}
|
||||
|
||||
// API Routes
|
||||
|
||||
// Customers
|
||||
app.get('/api/customers', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM customers ORDER BY name'
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/customers/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM customers WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/customers', async (req, res) => {
|
||||
const { name, street, city, state, zip_code, account_number } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO customers (name, street, city, state, zip_code, account_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, street, city, state, zip_code, account_number]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/customers/:id', async (req, res) => {
|
||||
const { name, street, city, state, zip_code, account_number } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE customers
|
||||
SET name = $1, street = $2, city = $3, state = $4,
|
||||
zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7
|
||||
RETURNING *`,
|
||||
[name, street, city, state, zip_code, account_number, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/customers/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM customers WHERE id = $1 RETURNING id',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
res.json({ message: 'Customer deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Quotes
|
||||
app.get('/api/quotes', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT q.*, c.name as customer_name
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
ORDER BY q.quote_number DESC`
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/quotes/:id', async (req, res) => {
|
||||
try {
|
||||
const quoteResult = await pool.query(
|
||||
`SELECT q.*, c.*
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
`SELECT * FROM quote_items
|
||||
WHERE quote_id = $1
|
||||
ORDER BY item_order`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
quote.items = itemsResult.rows;
|
||||
|
||||
res.json(quote);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/quotes', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { customer_id, quote_date, tax_exempt, items, tbd_note } = req.body;
|
||||
|
||||
// Generate quote number
|
||||
const quote_number = await generateQuoteNumber();
|
||||
|
||||
// Calculate totals
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.amount === 'TBD' || item.is_tbd) {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const tax_rate = tax_exempt ? 0 : 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
// Insert quote
|
||||
const quoteResult = await client.query(
|
||||
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt,
|
||||
tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[quote_number, customer_id, quote_date, tax_exempt, tax_rate,
|
||||
subtotal, tax_amount, total, has_tbd, tbd_note]
|
||||
);
|
||||
|
||||
const quote_id = quoteResult.rows[0].id;
|
||||
|
||||
// Insert items
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
await client.query(
|
||||
`INSERT INTO quote_items (quote_id, quantity, description, rate, amount, is_tbd, item_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[quote_id, item.quantity, item.description, item.rate,
|
||||
item.amount, item.is_tbd || false, i]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/quotes/:id', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { customer_id, quote_date, tax_exempt, items, tbd_note } = req.body;
|
||||
|
||||
// Calculate totals
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.amount === 'TBD' || item.is_tbd) {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const tax_rate = tax_exempt ? 0 : 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
// Update quote
|
||||
const quoteResult = await client.query(
|
||||
`UPDATE quotes
|
||||
SET customer_id = $1, quote_date = $2, tax_exempt = $3,
|
||||
tax_rate = $4, subtotal = $5, tax_amount = $6, total = $7,
|
||||
has_tbd = $8, tbd_note = $9, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $10
|
||||
RETURNING *`,
|
||||
[customer_id, quote_date, tax_exempt, tax_rate,
|
||||
subtotal, tax_amount, total, has_tbd, tbd_note, req.params.id]
|
||||
);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
// Delete old items
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [req.params.id]);
|
||||
|
||||
// Insert new items
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
await client.query(
|
||||
`INSERT INTO quote_items (quote_id, quantity, description, rate, amount, is_tbd, item_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[req.params.id, item.quantity, item.description, item.rate,
|
||||
item.amount, item.is_tbd || false, i]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/quotes/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM quotes WHERE id = $1 RETURNING id',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json({ message: 'Quote deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get next quote number
|
||||
app.get('/api/quotes/next-number', async (req, res) => {
|
||||
try {
|
||||
const quoteNumber = await generateQuoteNumber();
|
||||
res.json({ quote_number: quoteNumber });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error generating quote number' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload logo
|
||||
app.post('/api/upload-logo', upload.single('logo'), (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
res.json({
|
||||
filename: req.file.filename,
|
||||
path: `/uploads/${req.file.filename}`
|
||||
});
|
||||
});
|
||||
|
||||
// Generate PDF
|
||||
app.post('/api/quotes/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const quoteResult = await pool.query(
|
||||
`SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
`SELECT * FROM quote_items
|
||||
WHERE quote_id = $1
|
||||
ORDER BY item_order`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
quote.items = itemsResult.rows;
|
||||
|
||||
// Generate HTML for PDF
|
||||
const html = generateQuoteHTML(quote);
|
||||
|
||||
// Generate PDF with Puppeteer
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: 'Letter',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
left: '0'
|
||||
}
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
res.contentType('application/pdf');
|
||||
res.send(pdf);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error generating PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
function generateQuoteHTML(quote) {
|
||||
const formatCurrency = (amount) => {
|
||||
if (amount === 'TBD') return 'TBD';
|
||||
return parseFloat(amount).toFixed(2);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
let itemsHTML = '';
|
||||
quote.items.forEach(item => {
|
||||
itemsHTML += `
|
||||
<tr>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="description">${item.description}</td>
|
||||
<td class="rate">${item.rate}</td>
|
||||
<td class="amount">${item.amount}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
// Add sales tax row if not tax exempt
|
||||
if (!quote.tax_exempt) {
|
||||
itemsHTML += `
|
||||
<tr>
|
||||
<td class="qty"></td>
|
||||
<td class="description">Sales Tax</td>
|
||||
<td class="rate">${quote.tax_rate}%</td>
|
||||
<td class="amount">${formatCurrency(quote.tax_amount)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Total row
|
||||
const totalDisplay = quote.has_tbd ? `$${formatCurrency(quote.total)}*` : `$${formatCurrency(quote.total)}`;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="2" class="thank-you">This quote is valid for 14 days. We appreciate your business.</td>
|
||||
<td class="total-label">Total</td>
|
||||
<td class="total-amount">${totalDisplay}</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const tbdNote = quote.has_tbd && quote.tbd_note
|
||||
? `<p style="font-size: 12px; margin-top: 10px;">*${quote.tbd_note}</p>`
|
||||
: quote.has_tbd
|
||||
? `<p style="font-size: 12px; margin-top: 10px;">*Total excludes items marked as TBD which will be determined based on actual requirements.</p>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quote - Bay Area Affiliates, Inc.</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Times New Roman', Times, serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 8.5in;
|
||||
height:11in;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 40px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.company-details h1 {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.company-details p {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bill-to-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 30px 0 60px 0;
|
||||
}
|
||||
|
||||
.bill-to {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bill-to-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bill-to-address {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #fff;
|
||||
border: 1px solid #000;
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
background-color: #fff;
|
||||
border: 1px solid #000;
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.items-table td.qty {
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.items-table td.description {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items-table td.rate,
|
||||
.items-table td.amount {
|
||||
text-align: right;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.footer-row td {
|
||||
border: 1px solid #000;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding-right: 20px !important;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.thank-you {
|
||||
font-size: 13px;
|
||||
}
|
||||
.logo-size{
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="company-info">
|
||||
<img class="logo-size" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==">
|
||||
<div class="company-details">
|
||||
<h1>Bay Area Affiliates, Inc.</h1>
|
||||
<p>1001 Blucher Street<br>
|
||||
Corpus Christi, Texas 78401</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tagline">
|
||||
<em>Providing IT Services and Support in South Texas Since 1996</em>
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
Phone:<br>
|
||||
(361) 765-8400<br>
|
||||
(361) 765-8401<br>
|
||||
(361) 232-6578<br>
|
||||
Email:<br>
|
||||
support@bayarea-cc.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bill-to-section">
|
||||
<div class="bill-to">
|
||||
<div class="bill-to-label">Quote For:</div>
|
||||
<div class="bill-to-address">
|
||||
${quote.customer_name}<br>
|
||||
${quote.street}<br>
|
||||
${quote.city}, ${quote.state} ${quote.zip_code}
|
||||
</div>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QUOTE #</th>
|
||||
<th>ACCOUNT NO.</th>
|
||||
<th>DATE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>${quote.quote_number}</td>
|
||||
<td>${quote.account_number || ''}</td>
|
||||
<td>${formatDate(quote.quote_date)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QTY</th>
|
||||
<th>DESCRIPTION</th>
|
||||
<th>RATE</th>
|
||||
<th>AMOUNT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHTML}
|
||||
</tbody>
|
||||
</table>
|
||||
${tbdNote}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Quote System running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
@echo off
|
||||
echo =========================================
|
||||
echo Quote Management System - Bay Area Affiliates
|
||||
echo =========================================
|
||||
echo.
|
||||
|
||||
REM Check if Docker is running
|
||||
docker info >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Error: Docker is not running. Please start Docker Desktop first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting Quote Management System...
|
||||
echo.
|
||||
|
||||
REM Build and start containers
|
||||
docker-compose up -d
|
||||
|
||||
REM Wait for services to start
|
||||
echo Waiting for services to start...
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
REM Check if containers are running
|
||||
docker ps -q -f name=quote_app >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Error starting services. Check logs with: docker-compose logs
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Success! Quote Management System is running!
|
||||
echo.
|
||||
echo Access the application at: http://localhost:3000
|
||||
echo.
|
||||
echo To view logs: docker-compose logs -f
|
||||
echo To stop: docker-compose down
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "========================================="
|
||||
echo "Quote Management System - Bay Area Affiliates"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "Error: Docker is not running. Please start Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker-compose is available
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "Error: docker-compose is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting Quote Management System..."
|
||||
echo ""
|
||||
|
||||
# Build and start containers
|
||||
docker-compose up -d
|
||||
|
||||
# Wait for services to be ready
|
||||
echo "Waiting for services to start..."
|
||||
sleep 5
|
||||
|
||||
# Check if containers are running
|
||||
if [ "$(docker ps -q -f name=quote_app)" ] && [ "$(docker ps -q -f name=quote_postgres)" ]; then
|
||||
echo ""
|
||||
echo "✓ Quote Management System is running!"
|
||||
echo ""
|
||||
echo "Access the application at: http://localhost:3000"
|
||||
echo ""
|
||||
echo "To view logs: docker-compose logs -f"
|
||||
echo "To stop: docker-compose down"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
echo "✗ Error starting services. Check logs with: docker-compose logs"
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Reference in New Issue