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