Compare commits
66 Commits
2d5be21bf2
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
| 229e658831 | |||
| 5a7ba66c27 | |||
| b9f9df74c0 | |||
| d38195eae5 | |||
| e9d88b1400 | |||
| e333628f1c | |||
| 27ecafea5f | |||
| 15d33a116c | |||
| 0fbb298e89 | |||
| 6d0f4c49be | |||
| 7226883a2e | |||
| 198126c13e | |||
| c17cc362e4 | |||
| a9c190bbf6 | |||
| 39de7f7340 | |||
| bdfd096e99 | |||
| cc19cfcfad | |||
| 66736ef09d | |||
| 10380f26c4 | |||
| 5c86bd56aa | |||
| 8ce739d713 | |||
| b90a2a6340 | |||
| 667a4c2a48 | |||
| d47d52b3d1 | |||
| 8f68ed02c5 | |||
| 54c43fd052 | |||
| 503adf5bbc | |||
| 55b4cba35a | |||
| 053f01c5ec | |||
| cc41ed6ec9 | |||
| 326c45cca0 | |||
| 6b05917352 | |||
| ab2f064de9 | |||
| b5ac7f0807 | |||
| a8b82783b1 | |||
| 0750fd86b4 | |||
| 13f931978a | |||
| 731ac9f5d9 | |||
| b7db400e53 | |||
| 851ca7a037 | |||
| 503dd4051f | |||
| 73b869e2d9 | |||
| ec3cd2b659 | |||
| 29a37ad98a | |||
| be834fa9a0 | |||
| a0555eddd4 | |||
| 9a9cabdec6 | |||
| 9ebfd9b8c3 | |||
| 5e63adfee8 | |||
| c44fc7f63e | |||
| 4e6429e9ac | |||
| cbfbcf9b06 | |||
| 8643aebcfc | |||
| 7ba4eef5db | |||
| 444e8555f3 | |||
| 451f6f66c1 | |||
| 410faee6d1 | |||
| 49aeff8cb6 | |||
| 171450400a | |||
| a9465aa812 | |||
| b24a360fba | |||
| 48fa86916b | |||
| acb588425a | |||
| 2bb304babe | |||
| a0c62d639e | |||
| 84b0836234 |
11
.env.example
11
.env.example
@@ -8,3 +8,14 @@ DB_NAME=quotes_db
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# QBO API Credentials
|
||||||
|
QBO_CLIENT_ID=client_id
|
||||||
|
QBO_CLIENT_SECRET=client_secret
|
||||||
|
QBO_ENVIRONMENT=production
|
||||||
|
QBO_REDIRECT_URI=https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl
|
||||||
|
|
||||||
|
# QBO Tokens (aus dem Playground)
|
||||||
|
QBO_ACCESS_TOKEN=access_token
|
||||||
|
QBO_REFRESH_TOKEN=refresh_token
|
||||||
|
QBO_REALM_ID=realm_id
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
*.png
|
public/uploads/*.png
|
||||||
node_modules
|
node_modules
|
||||||
qbo_token.json
|
qbo_token.json
|
||||||
212
CHANGELOG.md
212
CHANGELOG.md
@@ -1,212 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -23,9 +23,10 @@ COPY package*.json ./
|
|||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY server.js ./
|
|
||||||
COPY qbo_helper.js ./
|
COPY qbo_helper.js ./
|
||||||
|
COPY src ./src
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY templates ./templates
|
||||||
|
|
||||||
# Create uploads directory
|
# Create uploads directory
|
||||||
RUN mkdir -p public/uploads && \
|
RUN mkdir -p public/uploads && \
|
||||||
@@ -38,5 +39,5 @@ EXPOSE 3000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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)})"
|
CMD node -e "require('http').get('http://localhost:3000/api/customers', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
|
||||||
|
|
||||||
# Start server
|
# Start server (using modular entry point)
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
# Invoice System Implementation Summary
|
|
||||||
|
|
||||||
## Übersicht / Overview
|
|
||||||
|
|
||||||
Dieses Dokument fasst die komplette Invoice-System-Implementierung für Bay Area Affiliates zusammen.
|
|
||||||
|
|
||||||
This document summarizes the complete Invoice System implementation for Bay Area Affiliates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Was wurde implementiert / What Was Implemented
|
|
||||||
|
|
||||||
### 1. Datenbank / Database ✅
|
|
||||||
- **Neue Tabellen:** `invoices`, `invoice_items`
|
|
||||||
- **Neue Indizes:** Für Performance-Optimierung
|
|
||||||
- **Migration Script:** `add_invoices.sql`
|
|
||||||
- **Rückwärtskompatibel:** Keine Änderungen an bestehenden Quotes
|
|
||||||
|
|
||||||
### 2. Backend (server.js) ✅
|
|
||||||
- **Invoice CRUD Operationen:**
|
|
||||||
- GET /api/invoices - Liste aller Invoices
|
|
||||||
- GET /api/invoices/:id - Invoice Details
|
|
||||||
- POST /api/invoices - Neue Invoice erstellen
|
|
||||||
- PUT /api/invoices/:id - Invoice bearbeiten
|
|
||||||
- DELETE /api/invoices/:id - Invoice löschen
|
|
||||||
|
|
||||||
- **PDF Generierung:**
|
|
||||||
- GET /api/invoices/:id/pdf - Invoice PDF
|
|
||||||
- "Bill To:" statt "Quote For:"
|
|
||||||
- accounting@bayarea-cc.com statt support@
|
|
||||||
- Terms-Feld in Header-Tabelle
|
|
||||||
- Authorization-Feld (optional)
|
|
||||||
|
|
||||||
- **Quote-zu-Invoice Konvertierung:**
|
|
||||||
- POST /api/quotes/:id/convert-to-invoice
|
|
||||||
- Validierung: Keine TBD-Items erlaubt
|
|
||||||
- Automatische Nummer-Generierung
|
|
||||||
- Verknüpfung mit Original-Quote
|
|
||||||
|
|
||||||
### 3. Frontend (app.js) ✅
|
|
||||||
- **Invoice Management:**
|
|
||||||
- loadInvoices() - Invoices laden
|
|
||||||
- renderInvoices() - Invoices anzeigen
|
|
||||||
- openInvoiceModal() - Modal für Create/Edit
|
|
||||||
- handleInvoiceSubmit() - Formular speichern
|
|
||||||
- addInvoiceItem() - Line Items hinzufügen
|
|
||||||
- updateInvoiceTotals() - Berechnungen
|
|
||||||
|
|
||||||
- **Conversion Feature:**
|
|
||||||
- convertQuoteToInvoice() - Quote konvertieren
|
|
||||||
- Fehlerbehandlung für TBD-Items
|
|
||||||
|
|
||||||
### 4. UI (index.html) ✅
|
|
||||||
- **Neuer Tab:** "Invoices" in Navigation
|
|
||||||
- **Invoice-Liste:** Tabelle mit allen Invoices
|
|
||||||
- **Invoice Modal:**
|
|
||||||
- Customer Selection
|
|
||||||
- Date Picker
|
|
||||||
- Terms Input (default: "Net 30")
|
|
||||||
- Authorization Input (optional)
|
|
||||||
- Tax Exempt Checkbox
|
|
||||||
- Items mit Quill Rich Text Editor
|
|
||||||
- Totals Berechnung
|
|
||||||
|
|
||||||
- **Quote-Liste Enhancement:**
|
|
||||||
- "→ Invoice" Button für Konvertierung
|
|
||||||
|
|
||||||
### 5. Dokumentation ✅
|
|
||||||
- **README.md:** Komplette Dokumentation
|
|
||||||
- **INSTALLATION.md:** Installations-Anleitung (DE/EN)
|
|
||||||
- **CHANGELOG.md:** Änderungsprotokoll
|
|
||||||
- **setup.sh:** Automatisches Setup-Script
|
|
||||||
|
|
||||||
### 6. Deployment ✅
|
|
||||||
- **Docker Support:**
|
|
||||||
- Dockerfile
|
|
||||||
- docker-compose.yml
|
|
||||||
- .dockerignore
|
|
||||||
|
|
||||||
- **Environment:**
|
|
||||||
- .env.example
|
|
||||||
- Konfigurierbare Settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Unterschiede: Quotes vs Invoices
|
|
||||||
|
|
||||||
| Feature | Quotes | Invoices |
|
|
||||||
|---------|--------|----------|
|
|
||||||
| **TBD Items** | ✅ Erlaubt | ❌ Nicht erlaubt |
|
|
||||||
| **Email** | support@bayarea-cc.com | accounting@bayarea-cc.com |
|
|
||||||
| **Label** | "Quote For:" | "Bill To:" |
|
|
||||||
| **Terms** | Nein | Ja (z.B. "Net 30") |
|
|
||||||
| **Authorization** | Nein | Ja (optional, P.O. etc.) |
|
|
||||||
| **Header Info** | Quote #, Account #, Date | Invoice #, Account #, Date, Terms |
|
|
||||||
| **Konvertierung** | → zu Invoice | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dateistruktur / File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
invoice-system/
|
|
||||||
├── server.js # Express Backend mit allen Routes
|
|
||||||
├── public/
|
|
||||||
│ ├── index.html # UI mit Tabs (Quotes/Invoices/Customers/Settings)
|
|
||||||
│ ├── app.js # Frontend JavaScript
|
|
||||||
│ └── uploads/ # Logo-Speicher
|
|
||||||
├── package.json # Dependencies
|
|
||||||
├── init.sql # Initial DB Schema (Customers, Quotes)
|
|
||||||
├── add_invoices.sql # Invoice Tables Migration
|
|
||||||
├── setup.sh # Auto-Installations-Script
|
|
||||||
├── .env.example # Environment Template
|
|
||||||
├── docker-compose.yml # Docker Deployment
|
|
||||||
├── Dockerfile # Container Image
|
|
||||||
├── README.md # Haupt-Dokumentation
|
|
||||||
├── INSTALLATION.md # Setup-Anleitung (DE/EN)
|
|
||||||
└── CHANGELOG.md # Versions-Historie
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation / Setup
|
|
||||||
|
|
||||||
### Schnellstart / Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Dateien entpacken
|
|
||||||
cd /installation/directory
|
|
||||||
|
|
||||||
# 2. Setup ausführen
|
|
||||||
chmod +x setup.sh
|
|
||||||
./setup.sh
|
|
||||||
|
|
||||||
# 3. Server starten
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 4. Browser öffnen
|
|
||||||
# http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build und Start
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Logs ansehen
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Stoppen
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validierungs-Regeln / Validation Rules
|
|
||||||
|
|
||||||
### Quote zu Invoice Konvertierung
|
|
||||||
|
|
||||||
**ERLAUBT / ALLOWED:**
|
|
||||||
```javascript
|
|
||||||
Quote Item: { qty: "2", rate: "125.00/hr", amount: "250.00" }
|
|
||||||
→ Kann konvertiert werden ✅
|
|
||||||
```
|
|
||||||
|
|
||||||
**NICHT ERLAUBT / NOT ALLOWED:**
|
|
||||||
```javascript
|
|
||||||
Quote Item: { qty: "2", rate: "TBD", amount: "TBD" }
|
|
||||||
→ Fehler: "Cannot convert quote with TBD items to invoice" ❌
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lösung / Solution:**
|
|
||||||
1. Quote bearbeiten
|
|
||||||
2. TBD durch tatsächliche Werte ersetzen
|
|
||||||
3. Quote speichern
|
|
||||||
4. Dann konvertieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Beispiele / API Examples
|
|
||||||
|
|
||||||
### Invoice erstellen / Create Invoice
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
POST /api/invoices
|
|
||||||
{
|
|
||||||
"customer_id": 1,
|
|
||||||
"invoice_date": "2026-01-31",
|
|
||||||
"terms": "Net 30",
|
|
||||||
"authorization": "P.O. #12345",
|
|
||||||
"tax_exempt": false,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"quantity": "2",
|
|
||||||
"description": "<p>Email Hosting - Monthly</p>",
|
|
||||||
"rate": "25.00",
|
|
||||||
"amount": "50.00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quote zu Invoice / Quote to Invoice
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
POST /api/quotes/5/convert-to-invoice
|
|
||||||
// Response bei Erfolg:
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"invoice_number": "2026-001",
|
|
||||||
"customer_id": 1,
|
|
||||||
"total": 54.13,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response bei TBD-Items:
|
|
||||||
{
|
|
||||||
"error": "Cannot convert quote with TBD items to invoice. Please update all TBD items first."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist ✅
|
|
||||||
|
|
||||||
- [x] Invoice erstellen
|
|
||||||
- [x] Invoice bearbeiten
|
|
||||||
- [x] Invoice löschen
|
|
||||||
- [x] Invoice PDF generieren
|
|
||||||
- [x] Quote ohne TBD zu Invoice konvertieren
|
|
||||||
- [x] Quote mit TBD Konvertierung blockieren
|
|
||||||
- [x] "Bill To:" Label im PDF
|
|
||||||
- [x] accounting@bayarea-cc.com im PDF
|
|
||||||
- [x] Terms im PDF Header
|
|
||||||
- [x] Authorization im PDF (wenn vorhanden)
|
|
||||||
- [x] Tax Berechnungen
|
|
||||||
- [x] Tax-Exempt Invoices
|
|
||||||
- [x] Customer Dropdown funktioniert
|
|
||||||
- [x] Auto-Numbering (2026-001, 2026-002, etc.)
|
|
||||||
- [x] Rich Text Editor in Items
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nächste Schritte / Next Steps
|
|
||||||
|
|
||||||
### Deployment auf deinem Server
|
|
||||||
1. Dateien hochladen
|
|
||||||
2. `setup.sh` ausführen
|
|
||||||
3. Logo hochladen (Settings Tab)
|
|
||||||
4. Ersten Customer erstellen
|
|
||||||
5. Test-Quote erstellen
|
|
||||||
6. Quote zu Invoice konvertieren
|
|
||||||
7. PDFs testen
|
|
||||||
|
|
||||||
### Optional: Docker
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup einrichten
|
|
||||||
```bash
|
|
||||||
# Cronjob für tägliches Backup
|
|
||||||
0 2 * * * pg_dump -U quoteuser quotes_db > /backups/quotes_$(date +\%Y\%m\%d).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support & Hilfe
|
|
||||||
|
|
||||||
- **Dokumentation:** README.md
|
|
||||||
- **Installation:** INSTALLATION.md
|
|
||||||
- **Änderungen:** CHANGELOG.md
|
|
||||||
- **Logs:** `journalctl -u quote-system -f` (systemd)
|
|
||||||
- **Docker Logs:** `docker-compose logs -f`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zusammenfassung / Summary
|
|
||||||
|
|
||||||
**Vollständiges Invoice-System implementiert mit:**
|
|
||||||
- ✅ Separate Invoice-Verwaltung
|
|
||||||
- ✅ Quote-zu-Invoice Konvertierung
|
|
||||||
- ✅ TBD-Validierung
|
|
||||||
- ✅ Professionelle PDFs
|
|
||||||
- ✅ Unterschiedliche Email-Adressen
|
|
||||||
- ✅ Terms & Authorization Felder
|
|
||||||
- ✅ Automatische Nummerierung
|
|
||||||
- ✅ Vollständige Dokumentation
|
|
||||||
- ✅ Docker Support
|
|
||||||
- ✅ Auto-Setup Script
|
|
||||||
- ✅ Rückwärtskompatibel
|
|
||||||
|
|
||||||
**Bereit für Produktion!** 🚀
|
|
||||||
264
INSTALLATION.md
264
INSTALLATION.md
@@ -1,264 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
67
auth.js
Normal file
67
auth.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const OAuthClient = require('intuit-oauth');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// 1. Konfiguration (Füge hier deine Development Keys ein)
|
||||||
|
|
||||||
|
const oauthClient = new OAuthClient({
|
||||||
|
clientId: process.env.QBO_CLIENT_ID,
|
||||||
|
clientSecret: process.env.QBO_CLIENT_SECRET,
|
||||||
|
environment: process.env.QBO_ENVIRONMENT, // Wichtig: 'sandbox' für Development Keys
|
||||||
|
redirectUri: process.env.QBO_REDIRECT_URI,
|
||||||
|
});
|
||||||
|
// 2. Start-Route: Generiert die Login-URL und leitet dich weiter
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
const authUri = oauthClient.authorizeUri({
|
||||||
|
scope: [OAuthClient.scopes.Accounting, OAuthClient.scopes.Payment],
|
||||||
|
state: 'testState',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Öffne Browser für Login...');
|
||||||
|
res.redirect(authUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Callback-Route: Hierhin kommt QBO zurück mit dem Code
|
||||||
|
app.get('/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 1. Tokens holen
|
||||||
|
const authResponse = await oauthClient.createToken(req.url);
|
||||||
|
const tokens = authResponse.getJson();
|
||||||
|
const realmId = authResponse.token.realmId;
|
||||||
|
|
||||||
|
// 2. Test-Abruf (Kunden)
|
||||||
|
const url = oauthClient.environment == 'sandbox'
|
||||||
|
? OAuthClient.environment.sandbox
|
||||||
|
: OAuthClient.environment.production;
|
||||||
|
|
||||||
|
const apiResponse = await oauthClient.makeApiCall({
|
||||||
|
url: `${url}v3/company/${realmId}/query?query=select * from Customer MAXRESULTS 5`,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Ausgabe in der Konsole
|
||||||
|
console.log('\n--- DEINE TOKENS (BITTE SICHERN) ---');
|
||||||
|
console.log('Realm ID:', realmId);
|
||||||
|
console.log('Access Token:', tokens.access_token);
|
||||||
|
console.log('Refresh Token:', tokens.refresh_token);
|
||||||
|
console.log('------------------------------------\n');
|
||||||
|
|
||||||
|
console.log("Test-Abruf Ergebnis:");
|
||||||
|
// KORREKTUR: .getJson() statt .text()
|
||||||
|
console.log(JSON.stringify(apiResponse.getJson(), null, 2));
|
||||||
|
|
||||||
|
// 4. Antwort an Browser (Erst ganz am Ende senden!)
|
||||||
|
res.send(`<h1>Erfolg!</h1><p>Tokens sind in der Konsole.</p>`);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Ein Fehler ist aufgetreten:", e);
|
||||||
|
// Nur senden, wenn noch nichts gesendet wurde
|
||||||
|
if (!res.headersSent) res.send('Fehler: Siehe Konsole');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server starten
|
||||||
|
app.listen(3000, async () => {
|
||||||
|
console.log('Server läuft auf http://localhost:3000');
|
||||||
|
});
|
||||||
@@ -39,6 +39,10 @@ services:
|
|||||||
QBO_REALM_ID: ${QBO_REALM_ID}
|
QBO_REALM_ID: ${QBO_REALM_ID}
|
||||||
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
||||||
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
||||||
|
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||||
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
- ./templates:/app/templates # NEU!
|
- ./templates:/app/templates # NEU!
|
||||||
|
|||||||
250
import_qbo_payment.js
Normal file
250
import_qbo_payment.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// import_qbo_payment.js — Importiert ein QBO Payment in die lokale DB
|
||||||
|
//
|
||||||
|
// Suche nach Payment über:
|
||||||
|
// --invoice <QBO_INVOICE_ID_OR_DOCNUMBER> Findet Payment über die Invoice
|
||||||
|
// --ref <CHECK_NUMBER> Findet Payment über Referenznummer
|
||||||
|
// --payment <QBO_PAYMENT_ID> Direkt über interne QBO Payment ID
|
||||||
|
//
|
||||||
|
// Beispiele:
|
||||||
|
// node import_qbo_payment.js --invoice 110483
|
||||||
|
// node import_qbo_payment.js --ref 20616
|
||||||
|
// node import_qbo_payment.js --payment 456
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
database: process.env.DB_NAME || 'quotes_db',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getBaseUrl() {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const base = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
return { base, companyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Suche: Payment über Invoice finden ---
|
||||||
|
async function findPaymentByInvoice(invoiceRef) {
|
||||||
|
const { base, companyId } = await getBaseUrl();
|
||||||
|
console.log(`\n🔍 Suche Invoice "${invoiceRef}" in QBO...`);
|
||||||
|
|
||||||
|
// Zuerst als DocNumber suchen (das ist was du siehst)
|
||||||
|
let invoice = null;
|
||||||
|
const query = `SELECT * FROM Invoice WHERE DocNumber = '${invoiceRef}'`;
|
||||||
|
const qRes = await makeQboApiCall({
|
||||||
|
url: `${base}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const qData = qRes.getJson ? qRes.getJson() : qRes.json;
|
||||||
|
|
||||||
|
if (qData.QueryResponse?.Invoice?.length > 0) {
|
||||||
|
invoice = qData.QueryResponse.Invoice[0];
|
||||||
|
} else {
|
||||||
|
// Fallback: direkt als ID versuchen
|
||||||
|
try {
|
||||||
|
const invRes = await makeQboApiCall({
|
||||||
|
url: `${base}/v3/company/${companyId}/invoice/${invoiceRef}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const invData = invRes.getJson ? invRes.getJson() : invRes.json;
|
||||||
|
invoice = invData.Invoice;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
console.error(`❌ Invoice "${invoiceRef}" nicht in QBO gefunden.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Invoice: ID ${invoice.Id}, DocNumber ${invoice.DocNumber}, Balance: $${invoice.Balance}, Kunde: ${invoice.CustomerRef?.name}`);
|
||||||
|
|
||||||
|
// Verknüpfte Payments aus LinkedTxn
|
||||||
|
if (invoice.LinkedTxn && invoice.LinkedTxn.length > 0) {
|
||||||
|
const paymentLinks = invoice.LinkedTxn.filter(lt => lt.TxnType === 'Payment');
|
||||||
|
if (paymentLinks.length > 0) {
|
||||||
|
console.log(` 📎 ${paymentLinks.length} verknüpfte Payment(s):`);
|
||||||
|
for (const pl of paymentLinks) {
|
||||||
|
console.log(` QBO Payment ID: ${pl.TxnId}`);
|
||||||
|
}
|
||||||
|
return paymentLinks[0].TxnId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ⚠️ Keine Payments verknüpft (Balance: $${invoice.Balance}).`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Suche: Payment über Reference Number ---
|
||||||
|
async function findPaymentByRef(refNumber) {
|
||||||
|
const { base, companyId } = await getBaseUrl();
|
||||||
|
console.log(`\n🔍 Suche Payment mit Reference Number "${refNumber}"...`);
|
||||||
|
|
||||||
|
const query = `SELECT * FROM Payment WHERE PaymentRefNum = '${refNumber}'`;
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${base}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const payments = data.QueryResponse?.Payment || [];
|
||||||
|
|
||||||
|
if (payments.length === 0) {
|
||||||
|
console.log(` ❌ Kein Payment mit Ref "${refNumber}" gefunden.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ ${payments.length} Payment(s) gefunden:`);
|
||||||
|
for (const p of payments) {
|
||||||
|
console.log(` ID: ${p.Id}, Datum: ${p.TxnDate}, Betrag: $${p.TotalAmt}, Ref: ${p.PaymentRefNum}`);
|
||||||
|
}
|
||||||
|
return payments[0].Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment laden und lokal importieren ---
|
||||||
|
async function importPayment(qboPaymentId) {
|
||||||
|
const { base, companyId } = await getBaseUrl();
|
||||||
|
console.log(`\n📥 Lade QBO Payment ID ${qboPaymentId}...`);
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${base}/v3/company/${companyId}/payment/${qboPaymentId}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const payment = data.Payment;
|
||||||
|
|
||||||
|
if (!payment) { console.error('❌ Payment nicht gefunden.'); return; }
|
||||||
|
|
||||||
|
console.log(`\n✅ Payment geladen:`);
|
||||||
|
console.log(` QBO ID: ${payment.Id}`);
|
||||||
|
console.log(` Datum: ${payment.TxnDate}`);
|
||||||
|
console.log(` Betrag: $${payment.TotalAmt}`);
|
||||||
|
console.log(` Referenz: ${payment.PaymentRefNum || '(keine)'}`);
|
||||||
|
console.log(` Kunde: ${payment.CustomerRef?.name || payment.CustomerRef?.value}`);
|
||||||
|
|
||||||
|
// Verknüpfte Invoices
|
||||||
|
const linkedInvoices = [];
|
||||||
|
if (payment.Line) {
|
||||||
|
for (const line of payment.Line) {
|
||||||
|
if (line.LinkedTxn) {
|
||||||
|
for (const txn of line.LinkedTxn) {
|
||||||
|
if (txn.TxnType === 'Invoice') {
|
||||||
|
linkedInvoices.push({ qbo_invoice_id: txn.TxnId, amount: line.Amount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Invoices: ${linkedInvoices.length}`);
|
||||||
|
linkedInvoices.forEach(li => console.log(` - QBO Invoice ${li.qbo_invoice_id}: $${li.amount}`));
|
||||||
|
|
||||||
|
// Namen auflösen
|
||||||
|
let paymentMethodName = 'Unknown';
|
||||||
|
if (payment.PaymentMethodRef?.value) {
|
||||||
|
try {
|
||||||
|
const pmRes = await makeQboApiCall({
|
||||||
|
url: `${base}/v3/company/${companyId}/paymentmethod/${payment.PaymentMethodRef.value}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
paymentMethodName = (pmRes.getJson ? pmRes.getJson() : pmRes.json).PaymentMethod?.Name || 'Unknown';
|
||||||
|
} catch (e) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let depositToName = '';
|
||||||
|
if (payment.DepositToAccountRef?.value) {
|
||||||
|
try {
|
||||||
|
const accRes = await makeQboApiCall({
|
||||||
|
url: `${base}/v3/company/${companyId}/account/${payment.DepositToAccountRef.value}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
depositToName = (accRes.getJson ? accRes.getJson() : accRes.json).Account?.Name || '';
|
||||||
|
} catch (e) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Methode: ${paymentMethodName}`);
|
||||||
|
console.log(` Konto: ${depositToName}`);
|
||||||
|
|
||||||
|
// --- DB ---
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
try {
|
||||||
|
const existing = await dbClient.query('SELECT id FROM payments WHERE qbo_payment_id = $1', [String(payment.Id)]);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
console.log(`\n⚠️ Bereits importiert (lokale ID: ${existing.rows[0].id}). Übersprungen.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
const custResult = await dbClient.query('SELECT id FROM customers WHERE qbo_id = $1', [payment.CustomerRef?.value]);
|
||||||
|
const customerId = custResult.rows[0]?.id || null;
|
||||||
|
|
||||||
|
const payResult = await dbClient.query(
|
||||||
|
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
|
[payment.TxnDate, payment.PaymentRefNum || null, paymentMethodName, depositToName, payment.TotalAmt, customerId, String(payment.Id)]
|
||||||
|
);
|
||||||
|
const localId = payResult.rows[0].id;
|
||||||
|
console.log(`\n💾 Payment lokal gespeichert: ID ${localId}`);
|
||||||
|
|
||||||
|
let matched = 0;
|
||||||
|
for (const li of linkedInvoices) {
|
||||||
|
const invResult = await dbClient.query('SELECT id, invoice_number FROM invoices WHERE qbo_id = $1', [li.qbo_invoice_id]);
|
||||||
|
if (invResult.rows.length > 0) {
|
||||||
|
const inv = invResult.rows[0];
|
||||||
|
await dbClient.query('INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [localId, inv.id, li.amount]);
|
||||||
|
await dbClient.query('UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND paid_date IS NULL', [payment.TxnDate, inv.id]);
|
||||||
|
console.log(` ✅ Invoice #${inv.invoice_number || inv.id} → bezahlt`);
|
||||||
|
matched++;
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ QBO Invoice ${li.qbo_invoice_id} nicht in lokaler DB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
console.log(`\n✅ Fertig: ${matched}/${linkedInvoices.length} Invoices verknüpft.`);
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length < 2) {
|
||||||
|
console.log(`
|
||||||
|
Verwendung:
|
||||||
|
node import_qbo_payment.js --invoice <DOCNUMBER_OR_QBO_ID>
|
||||||
|
node import_qbo_payment.js --ref <CHECK_NUMBER>
|
||||||
|
node import_qbo_payment.js --payment <QBO_PAYMENT_ID>
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
node import_qbo_payment.js --invoice 110483
|
||||||
|
node import_qbo_payment.js --ref 20616
|
||||||
|
node import_qbo_payment.js --payment 456
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let qboPaymentId = null;
|
||||||
|
if (args[0] === '--payment') qboPaymentId = args[1];
|
||||||
|
else if (args[0] === '--invoice') qboPaymentId = await findPaymentByInvoice(args[1]);
|
||||||
|
else if (args[0] === '--ref') qboPaymentId = await findPaymentByRef(args[1]);
|
||||||
|
else { console.error(`Unbekannt: ${args[0]}`); process.exit(1); }
|
||||||
|
|
||||||
|
if (!qboPaymentId) { console.error('\n❌ Payment nicht gefunden.'); process.exit(1); }
|
||||||
|
|
||||||
|
await importPayment(qboPaymentId);
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||||
53
init.sql
53
init.sql
@@ -1,53 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
2865
package-lock.json
generated
2865
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -2,19 +2,23 @@
|
|||||||
"name": "quote-invoice-system",
|
"name": "quote-invoice-system",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Quote & Invoice Management System for Bay Area Affiliates",
|
"description": "Quote & Invoice Management System for Bay Area Affiliates",
|
||||||
"main": "server.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-sesv2": "^3.1009.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"intuit-oauth": "^4.2.2",
|
"intuit-oauth": "^4.2.2",
|
||||||
|
"mjml": "^4.18.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"puppeteer": "^23.11.1"
|
"puppeteer": "^23.11.1",
|
||||||
|
"stripe": "^20.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
1245
public/app.js
1245
public/app.js
File diff suppressed because it is too large
Load Diff
54
public/css/styles.css
Normal file
54
public/css/styles.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/* styles.css — Application styles extracted from index.html */
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invoice/Quote Modal — visible field borders */
|
||||||
|
#invoice-modal input,
|
||||||
|
#invoice-modal select,
|
||||||
|
#invoice-modal textarea,
|
||||||
|
#quote-modal input,
|
||||||
|
#quote-modal select,
|
||||||
|
#quote-modal textarea {
|
||||||
|
border: 1.5px solid #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#invoice-modal input:focus,
|
||||||
|
#invoice-modal select:focus,
|
||||||
|
#invoice-modal textarea:focus,
|
||||||
|
#quote-modal input:focus,
|
||||||
|
#quote-modal select:focus,
|
||||||
|
#quote-modal textarea:focus {
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rich Text Editor borders */
|
||||||
|
#invoice-modal .ql-container,
|
||||||
|
#invoice-modal .ql-toolbar,
|
||||||
|
#quote-modal .ql-container,
|
||||||
|
#quote-modal .ql-toolbar {
|
||||||
|
border: 1.5px solid #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row input,
|
||||||
|
.item-row select,
|
||||||
|
.invoice-item input,
|
||||||
|
.invoice-item select,
|
||||||
|
#invoice-items input,
|
||||||
|
#invoice-items select,
|
||||||
|
#quote-items input,
|
||||||
|
#quote-items select {
|
||||||
|
border: 1.5px solid #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#invoice-items > div,
|
||||||
|
#quote-items > div,
|
||||||
|
#invoice-items .border,
|
||||||
|
#quote-items .border {
|
||||||
|
border: 1.5px solid #9ca3af !important;
|
||||||
|
}
|
||||||
BIN
public/favicon-192x192.png
Normal file
BIN
public/favicon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 933 B |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -7,20 +7,17 @@
|
|||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
<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>
|
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||||
|
<script src="js/components/customer-search.js"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
.modal {
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||||
display: none;
|
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
||||||
}
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
.modal.active {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="bg-blue-900 text-white shadow-lg">
|
<nav class="bg-blue-900 text-white shadow-lg sticky top-0 z-40">
|
||||||
<div class="container mx-auto px-6 py-4">
|
<div class="container mx-auto px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -74,19 +71,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar wird von invoice-view.js injiziert -->
|
|
||||||
<div id="invoice-toolbar"></div>
|
<div id="invoice-toolbar"></div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
|
<th class="px-4 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-4 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-4 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-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Send 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-4 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">Actions</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
|
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
|
||||||
@@ -97,21 +94,16 @@
|
|||||||
|
|
||||||
<!-- Customers Tab -->
|
<!-- Customers Tab -->
|
||||||
<div id="customers-tab" class="tab-content hidden">
|
<div id="customers-tab" class="tab-content hidden">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div id="customer-toolbar"></div>
|
||||||
<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">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
<th class="px-4 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-4 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-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="customers-list" class="bg-white divide-y divide-gray-200">
|
<tbody id="customers-list" class="bg-white divide-y divide-gray-200">
|
||||||
@@ -167,6 +159,9 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
||||||
|
|
||||||
|
<hr class="my-8 border-gray-200">
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
||||||
<p class="text-gray-600 mb-4">
|
<p class="text-gray-600 mb-4">
|
||||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||||
@@ -182,7 +177,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// QBO Status beim Laden prüfen
|
|
||||||
fetch('/api/qbo/status')
|
fetch('/api/qbo/status')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -198,6 +192,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<hr class="my-8 border-gray-200">
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
||||||
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
||||||
|
|
||||||
@@ -227,106 +223,6 @@
|
|||||||
</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 class="space-y-3 pt-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700">Billing Address</label>
|
|
||||||
|
|
||||||
<input type="text" id="customer-line1" placeholder="Line 1 (Street / PO Box / Company)"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
|
|
||||||
<input type="text" id="customer-line2" placeholder="Line 2"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<input type="text" id="customer-line3" placeholder="Line 3"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
|
|
||||||
<input type="text" id="customer-line4" placeholder="Line 4"
|
|
||||||
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-3 gap-4 pt-2">
|
|
||||||
<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"
|
|
||||||
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" 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"
|
|
||||||
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="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
|
||||||
<input type="email" id="customer-email"
|
|
||||||
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">Phone</label>
|
|
||||||
<input type="tel" id="customer-phone"
|
|
||||||
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="pt-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input type="checkbox" id="customer-taxable"
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
||||||
<label for="customer-taxable" class="ml-2 block text-sm text-gray-900">Taxable</label>
|
|
||||||
</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 -->
|
<!-- 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 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="relative mx-auto p-8 border w-full max-w-6xl shadow-lg rounded-lg bg-white my-8">
|
||||||
@@ -419,13 +315,9 @@
|
|||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeQuoteModal()"
|
<button type="button" onclick="closeQuoteModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote</button>
|
||||||
Save Quote
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,12 +336,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="invoice-form" class="space-y-6">
|
<form id="invoice-form" class="space-y-6">
|
||||||
<div class="grid grid-cols-5 gap-4">
|
<div class="grid grid-cols-6 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
|
||||||
<input type="text" id="invoice-number" required pattern="[0-9]+"
|
<input type="text" id="invoice-number" pattern="[0-9]*"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
placeholder="Auto (QBO)"
|
||||||
title="Must be a numeric value">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
|
||||||
|
title="Optional — wird beim QBO Export automatisch vergeben">
|
||||||
</div>
|
</div>
|
||||||
<div x-data="customerSearch('invoice')" class="relative">
|
<div x-data="customerSearch('invoice')" class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||||||
@@ -487,21 +380,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Bill To Name (optional)</label>
|
||||||
|
<input type="text" id="invoice-bill-to-name" placeholder="Default: Company name"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
||||||
<input type="date" id="invoice-date" required
|
<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">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Send Date</label>
|
||||||
|
<input type="date" id="invoice-send-date"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
title="Wann soll die Rechnung versendet werden?">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
|
||||||
<input type="text" id="invoice-terms" value="Net 30" required
|
<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">
|
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 pt-6">
|
<div class="flex items-center gap-6 pt-6 w-max">
|
||||||
|
<div class="flex items-center">
|
||||||
<input type="checkbox" id="invoice-tax-exempt"
|
<input type="checkbox" id="invoice-tax-exempt"
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
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>
|
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="invoice-recurring"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<label for="invoice-recurring" class="text-sm text-gray-900">Recurring</label>
|
||||||
|
<div id="invoice-recurring-group" style="display: none;">
|
||||||
|
<select id="invoice-recurring-interval"
|
||||||
|
class="px-2 py-1 border border-gray-300 rounded-md text-sm bg-white">
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -541,19 +459,15 @@
|
|||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeInvoiceModal()"
|
<button type="button" onclick="closeInvoiceModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice</button>
|
||||||
Save Invoice
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<!-- Single module entry point — all JS loaded from here -->
|
||||||
<script type="module" src="invoice-view-init.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// invoice-view-init.js — Bootstrap-Script (type="module")
|
|
||||||
// Wird in index.html als <script type="module"> geladen.
|
|
||||||
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
|
|
||||||
|
|
||||||
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
|
||||||
|
|
||||||
// Warte bis DOM fertig ist
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
// Toolbar injizieren
|
|
||||||
injectToolbar();
|
|
||||||
|
|
||||||
// Globale Funktionen für app.js verfügbar machen
|
|
||||||
// (app.js ruft loadInvoices() auf wenn der Tab gewechselt wird)
|
|
||||||
window.loadInvoices = loadInvoices;
|
|
||||||
window.renderInvoices = renderInvoiceView;
|
|
||||||
|
|
||||||
// Initiales Laden
|
|
||||||
loadInvoices();
|
|
||||||
}
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
// invoice-view.js — ES Module für die Invoice View
|
|
||||||
// Features: Status Filter (all/unpaid/paid/overdue), Customer Filter,
|
|
||||||
// Group by (none/week/month), Sortierung neueste zuerst, Mark Paid/Unpaid
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// State
|
|
||||||
// ============================================================
|
|
||||||
let invoices = [];
|
|
||||||
let filterCustomer = '';
|
|
||||||
let filterStatus = 'unpaid'; // 'all' | 'unpaid' | 'paid' | 'overdue'
|
|
||||||
let groupBy = 'none'; // 'none' | 'week' | 'month'
|
|
||||||
|
|
||||||
const OVERDUE_DAYS = 30;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Helpers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysSince(date) {
|
|
||||||
const d = new Date(date);
|
|
||||||
const now = new Date();
|
|
||||||
return Math.floor((now - d) / 86400000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekNumber(date) {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setHours(0, 0, 0, 0);
|
|
||||||
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
|
||||||
const week1 = new Date(d.getFullYear(), 0, 4);
|
|
||||||
return {
|
|
||||||
year: d.getFullYear(),
|
|
||||||
week: 1 + Math.round(((d - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekRange(year, weekNum) {
|
|
||||||
const jan4 = new Date(year, 0, 4);
|
|
||||||
const dayOfWeek = jan4.getDay() || 7;
|
|
||||||
const monday = new Date(jan4);
|
|
||||||
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
|
|
||||||
const sunday = new Date(monday);
|
|
||||||
sunday.setDate(monday.getDate() + 6);
|
|
||||||
return { start: formatDate(monday), end: formatDate(sunday) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMonthName(monthIndex) {
|
|
||||||
return ['January','February','March','April','May','June',
|
|
||||||
'July','August','September','October','November','December'][monthIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPaid(inv) {
|
|
||||||
return !!inv.paid_date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOverdue(inv) {
|
|
||||||
return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Data Loading
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export async function loadInvoices() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/invoices');
|
|
||||||
invoices = await response.json();
|
|
||||||
renderInvoiceView();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading invoices:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInvoicesData() {
|
|
||||||
return invoices;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Filtering & Sorting & Grouping
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function getFilteredInvoices() {
|
|
||||||
let filtered = [...invoices];
|
|
||||||
|
|
||||||
// Status Filter
|
|
||||||
if (filterStatus === 'unpaid') {
|
|
||||||
filtered = filtered.filter(inv => !isPaid(inv));
|
|
||||||
} else if (filterStatus === 'paid') {
|
|
||||||
filtered = filtered.filter(inv => isPaid(inv));
|
|
||||||
} else if (filterStatus === 'overdue') {
|
|
||||||
filtered = filtered.filter(inv => isOverdue(inv));
|
|
||||||
}
|
|
||||||
// 'all' → kein Filter
|
|
||||||
|
|
||||||
// Customer Filter
|
|
||||||
if (filterCustomer.trim()) {
|
|
||||||
const search = filterCustomer.toLowerCase();
|
|
||||||
filtered = filtered.filter(inv =>
|
|
||||||
(inv.customer_name || '').toLowerCase().includes(search)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortierung: neueste zuerst
|
|
||||||
filtered.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupInvoices(filtered) {
|
|
||||||
if (groupBy === 'none') return null;
|
|
||||||
|
|
||||||
const groups = new Map();
|
|
||||||
|
|
||||||
filtered.forEach(inv => {
|
|
||||||
const d = new Date(inv.invoice_date);
|
|
||||||
let key, label;
|
|
||||||
|
|
||||||
if (groupBy === 'week') {
|
|
||||||
const wk = getWeekNumber(inv.invoice_date);
|
|
||||||
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
|
|
||||||
const range = getWeekRange(wk.year, wk.week);
|
|
||||||
label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`;
|
|
||||||
} else if (groupBy === 'month') {
|
|
||||||
const month = d.getMonth();
|
|
||||||
const year = d.getFullYear();
|
|
||||||
key = `${year}-${String(month).padStart(2, '0')}`;
|
|
||||||
label = `${getMonthName(month)} ${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groups.has(key)) {
|
|
||||||
groups.set(key, { label, invoices: [], total: 0 });
|
|
||||||
}
|
|
||||||
const group = groups.get(key);
|
|
||||||
group.invoices.push(inv);
|
|
||||||
group.total += parseFloat(inv.total) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Innerhalb jeder Gruppe nochmal nach Datum sortieren (neueste zuerst)
|
|
||||||
for (const group of groups.values()) {
|
|
||||||
group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gruppen nach Key sortieren (neueste zuerst)
|
|
||||||
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Rendering
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function renderInvoiceRow(invoice) {
|
|
||||||
const hasQbo = !!invoice.qbo_id;
|
|
||||||
const paid = isPaid(invoice);
|
|
||||||
const overdue = isOverdue(invoice);
|
|
||||||
|
|
||||||
// QBO Button
|
|
||||||
const qboButton = hasQbo
|
|
||||||
? `<span class="text-gray-400 text-xs" title="Already in QBO (ID: ${invoice.qbo_id})">✓ QBO</span>`
|
|
||||||
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
|
||||||
|
|
||||||
// Paid/Unpaid Toggle Button
|
|
||||||
const paidButton = paid
|
|
||||||
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`
|
|
||||||
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
|
|
||||||
|
|
||||||
// Status Badge
|
|
||||||
let statusBadge = '';
|
|
||||||
if (paid) {
|
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
|
||||||
} else if (overdue) {
|
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days old">Overdue</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row styling
|
|
||||||
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr class="${rowClass}">
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
${invoice.invoice_number} ${statusBadge}
|
|
||||||
</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="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
|
||||||
<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>
|
|
||||||
${qboButton}
|
|
||||||
${paidButton}
|
|
||||||
<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
|
||||||
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGroupHeader(label) {
|
|
||||||
return `
|
|
||||||
<tr class="bg-blue-50">
|
|
||||||
<td colspan="6" class="px-6 py-3 text-sm font-bold text-blue-800">
|
|
||||||
📅 ${label}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGroupFooter(total, count) {
|
|
||||||
return `
|
|
||||||
<tr class="bg-gray-50 border-t-2 border-gray-300">
|
|
||||||
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
|
|
||||||
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderInvoiceView() {
|
|
||||||
const tbody = document.getElementById('invoices-list');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
const filtered = getFilteredInvoices();
|
|
||||||
const groups = groupInvoices(filtered);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
let grandTotal = 0;
|
|
||||||
|
|
||||||
if (groups) {
|
|
||||||
for (const [key, group] of groups) {
|
|
||||||
html += renderGroupHeader(group.label);
|
|
||||||
group.invoices.forEach(inv => {
|
|
||||||
html += renderInvoiceRow(inv);
|
|
||||||
});
|
|
||||||
html += renderGroupFooter(group.total, group.invoices.length);
|
|
||||||
grandTotal += group.total;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groups.size > 1) {
|
|
||||||
html += `
|
|
||||||
<tr class="bg-blue-100 border-t-4 border-blue-400">
|
|
||||||
<td colspan="4" class="px-6 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
|
|
||||||
<td class="px-6 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filtered.forEach(inv => {
|
|
||||||
html += renderInvoiceRow(inv);
|
|
||||||
grandTotal += parseFloat(inv.total) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
html += `
|
|
||||||
<tr class="bg-gray-100 border-t-2 border-gray-300">
|
|
||||||
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
|
|
||||||
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
html = `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = html;
|
|
||||||
|
|
||||||
// Update count badge
|
|
||||||
const countEl = document.getElementById('invoice-count');
|
|
||||||
if (countEl) countEl.textContent = filtered.length;
|
|
||||||
|
|
||||||
// Update status button active states
|
|
||||||
updateStatusButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatusButtons() {
|
|
||||||
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
|
||||||
const status = btn.getAttribute('data-status-filter');
|
|
||||||
if (status === filterStatus) {
|
|
||||||
btn.classList.remove('bg-white', 'text-gray-600');
|
|
||||||
btn.classList.add('bg-blue-600', 'text-white');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('bg-blue-600', 'text-white');
|
|
||||||
btn.classList.add('bg-white', 'text-gray-600');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update overdue count badge
|
|
||||||
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
|
|
||||||
const overdueBadge = document.getElementById('overdue-badge');
|
|
||||||
if (overdueBadge) {
|
|
||||||
if (overdueCount > 0) {
|
|
||||||
overdueBadge.textContent = overdueCount;
|
|
||||||
overdueBadge.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
overdueBadge.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update unpaid count
|
|
||||||
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
|
|
||||||
const unpaidBadge = document.getElementById('unpaid-badge');
|
|
||||||
if (unpaidBadge) {
|
|
||||||
unpaidBadge.textContent = unpaidCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Toolbar HTML
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function injectToolbar() {
|
|
||||||
const container = document.getElementById('invoice-toolbar');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<!-- Status Filter Buttons -->
|
|
||||||
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
|
||||||
<button data-status-filter="all"
|
|
||||||
onclick="window.invoiceView.setStatus('all')"
|
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button data-status-filter="unpaid"
|
|
||||||
onclick="window.invoiceView.setStatus('unpaid')"
|
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-blue-600 text-white">
|
|
||||||
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span>
|
|
||||||
</button>
|
|
||||||
<button data-status-filter="paid"
|
|
||||||
onclick="window.invoiceView.setStatus('paid')"
|
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
|
||||||
Paid
|
|
||||||
</button>
|
|
||||||
<button data-status-filter="overdue"
|
|
||||||
onclick="window.invoiceView.setStatus('overdue')"
|
|
||||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
|
||||||
Overdue
|
|
||||||
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-px h-8 bg-gray-300"></div>
|
|
||||||
|
|
||||||
<!-- Customer Filter -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
|
||||||
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..."
|
|
||||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-px h-8 bg-gray-300"></div>
|
|
||||||
|
|
||||||
<!-- Group By -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label class="text-sm font-medium text-gray-700">Group:</label>
|
|
||||||
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
<option value="none">None</option>
|
|
||||||
<option value="week">Week</option>
|
|
||||||
<option value="month">Month</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoice Count -->
|
|
||||||
<div class="ml-auto text-sm text-gray-500">
|
|
||||||
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
|
||||||
filterCustomer = e.target.value;
|
|
||||||
renderInvoiceView();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
|
|
||||||
groupBy = e.target.value;
|
|
||||||
renderInvoiceView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Actions
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function setStatus(status) {
|
|
||||||
filterStatus = status;
|
|
||||||
renderInvoiceView();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function viewPDF(id) {
|
|
||||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function viewHTML(id) {
|
|
||||||
window.open(`/api/invoices/${id}/html`, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportToQBO(id) {
|
|
||||||
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
|
|
||||||
|
|
||||||
const btn = event.target;
|
|
||||||
const originalText = btn.textContent;
|
|
||||||
btn.textContent = "⏳...";
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert(`✅ Erfolg! QBO ID: ${result.qbo_id}, Rechnungsnr: ${result.qbo_doc_number}`);
|
|
||||||
loadInvoices();
|
|
||||||
} else {
|
|
||||||
alert(`❌ Fehler: ${result.error}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert('Netzwerkfehler beim Export.');
|
|
||||||
} finally {
|
|
||||||
btn.textContent = originalText;
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markPaid(id) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/invoices/${id}/mark-paid`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
loadInvoices();
|
|
||||||
} else {
|
|
||||||
const err = await response.json();
|
|
||||||
alert('Error: ' + (err.error || 'Unknown'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error marking paid:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markUnpaid(id) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' });
|
|
||||||
if (response.ok) {
|
|
||||||
loadInvoices();
|
|
||||||
} else {
|
|
||||||
const err = await response.json();
|
|
||||||
alert('Error: ' + (err.error || 'Unknown'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error marking unpaid:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function edit(id) {
|
|
||||||
if (typeof window.openInvoiceModal === 'function') {
|
|
||||||
await window.openInvoiceModal(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Expose to window
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
window.invoiceView = {
|
|
||||||
viewPDF,
|
|
||||||
viewHTML,
|
|
||||||
exportToQBO,
|
|
||||||
markPaid,
|
|
||||||
markUnpaid,
|
|
||||||
edit,
|
|
||||||
remove,
|
|
||||||
loadInvoices,
|
|
||||||
renderInvoiceView,
|
|
||||||
setStatus
|
|
||||||
};
|
|
||||||
86
public/js/app.js
Normal file
86
public/js/app.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* app.js — Application Bootstrap
|
||||||
|
*
|
||||||
|
* This is the main entry point. All business logic has been moved to modules:
|
||||||
|
* - js/views/quote-view.js → Quote list
|
||||||
|
* - js/views/invoice-view.js → Invoice list (existing)
|
||||||
|
* - js/views/settings-view.js → Logo, QBO import/test
|
||||||
|
* - js/modals/quote-modal.js → Quote create/edit
|
||||||
|
* - js/modals/invoice-modal.js → Invoice create/edit
|
||||||
|
* - js/modals/payment-modal.js → Payment recording (existing)
|
||||||
|
* - js/components/customer-search.js → Alpine dropdown
|
||||||
|
* - js/utils/item-editor.js → Shared accordion item editor
|
||||||
|
* - js/utils/helpers.js → formatDate, spinner
|
||||||
|
* - js/utils/api.js → API wrapper (existing)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Imports ---
|
||||||
|
import { loadQuotes } from './views/quote-view.js';
|
||||||
|
import { loadInvoices, injectToolbar as injectInvoiceToolbar, renderInvoiceView } from './views/invoice-view.js';
|
||||||
|
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './views/customer-view.js';
|
||||||
|
import { checkCurrentLogo, initSettingsView } from './views/settings-view.js';
|
||||||
|
import { initQuoteModal } from './modals/quote-modal.js';
|
||||||
|
import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js';
|
||||||
|
import './modals/payment-modal.js';
|
||||||
|
import './modals/email-modal.js';
|
||||||
|
import { setDefaultDate } from './utils/helpers.js';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
localStorage.setItem('activeTab', tabName);
|
||||||
|
|
||||||
|
if (tabName === 'quotes') {
|
||||||
|
loadQuotes();
|
||||||
|
} else if (tabName === 'invoices') {
|
||||||
|
injectInvoiceToolbar();
|
||||||
|
loadInvoices();
|
||||||
|
} else if (tabName === 'customers') {
|
||||||
|
injectCustomerToolbar();
|
||||||
|
renderCustomerView();
|
||||||
|
} else if (tabName === 'settings') {
|
||||||
|
checkCurrentLogo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Init
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Load shared data
|
||||||
|
loadCustomers();
|
||||||
|
loadLaborRate();
|
||||||
|
setDefaultDate();
|
||||||
|
|
||||||
|
// Init modals (wire up form handlers)
|
||||||
|
initQuoteModal();
|
||||||
|
initInvoiceModal();
|
||||||
|
initSettingsView();
|
||||||
|
|
||||||
|
// Restore saved tab (or default to quotes)
|
||||||
|
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||||
|
showTab(savedTab);
|
||||||
|
|
||||||
|
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
||||||
|
if (window.location.hash) {
|
||||||
|
const hashTab = window.location.hash.replace('#', '');
|
||||||
|
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
|
||||||
|
showTab(hashTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose to HTML onclick handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.showTab = showTab;
|
||||||
67
public/js/components/customer-search.js
Normal file
67
public/js/components/customer-search.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* customer-search.js — Alpine.js Customer Search Component
|
||||||
|
* Used in Quote and Invoice modals for customer dropdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
function customerSearch(type) {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
selectedId: '',
|
||||||
|
selectedName: '',
|
||||||
|
open: false,
|
||||||
|
highlighted: 0,
|
||||||
|
|
||||||
|
get filteredCustomers() {
|
||||||
|
const allCustomers = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||||
|
|
||||||
|
if (!this.search) {
|
||||||
|
return allCustomers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchLower = this.search.toLowerCase();
|
||||||
|
return allCustomers.filter(c =>
|
||||||
|
(c.name || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(c.line1 || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(c.city || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(c.account_number && c.account_number.includes(searchLower))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectCustomer(customer) {
|
||||||
|
this.selectedId = customer.id;
|
||||||
|
this.selectedName = customer.name;
|
||||||
|
this.search = customer.name;
|
||||||
|
this.open = false;
|
||||||
|
this.highlighted = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightNext() {
|
||||||
|
if (this.highlighted < this.filteredCustomers.length - 1) {
|
||||||
|
this.highlighted++;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPrev() {
|
||||||
|
if (this.highlighted > 0) {
|
||||||
|
this.highlighted--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectHighlighted() {
|
||||||
|
if (this.filteredCustomers[this.highlighted]) {
|
||||||
|
this.selectCustomer(this.filteredCustomers[this.highlighted]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.search = '';
|
||||||
|
this.selectedId = '';
|
||||||
|
this.selectedName = '';
|
||||||
|
this.open = false;
|
||||||
|
this.highlighted = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make globally available for Alpine x-data
|
||||||
|
window.customerSearch = customerSearch;
|
||||||
296
public/js/modals/email-modal.js
Normal file
296
public/js/modals/email-modal.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
// email-modal.js — ES Module
|
||||||
|
// Modal to review and send invoice emails via AWS SES
|
||||||
|
// With Stripe Payment Link integration
|
||||||
|
|
||||||
|
import { showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let currentInvoice = null;
|
||||||
|
let quillInstance = null;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DOM & Render
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function ensureModalElement() {
|
||||||
|
let modal = document.getElementById('email-modal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'email-modal';
|
||||||
|
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto hidden';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModalContent() {
|
||||||
|
const modal = document.getElementById('email-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const defaultEmail = currentInvoice.email || '';
|
||||||
|
const existingStripeUrl = currentInvoice.stripe_payment_link_url || '';
|
||||||
|
const stripeStatus = currentInvoice.stripe_payment_status || '';
|
||||||
|
|
||||||
|
// Status indicator for existing link
|
||||||
|
let stripeBadgeHtml = '';
|
||||||
|
if (existingStripeUrl && stripeStatus === 'paid') {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">Paid</span>';
|
||||||
|
} else if (existingStripeUrl && stripeStatus === 'processing') {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Processing</span>';
|
||||||
|
} else if (existingStripeUrl) {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">Active</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-3xl mx-auto p-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800">📤 Send Invoice #${currentInvoice.invoice_number || currentInvoice.id}</h2>
|
||||||
|
<button onclick="window.emailModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="email-send-form" class="space-y-5">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Recipient Email *</label>
|
||||||
|
<input type="email" id="email-recipient" value="${defaultEmail}" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Stripe Payment Link${stripeBadgeHtml}
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="url" id="email-stripe-link" value="${existingStripeUrl}" readonly
|
||||||
|
placeholder="Click Generate to create link..."
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-600 focus:ring-purple-500 focus:border-purple-500">
|
||||||
|
<button type="button" id="stripe-generate-btn" onclick="window.emailModal.generateStripeLink()"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm font-semibold whitespace-nowrap">
|
||||||
|
${existingStripeUrl ? '♻️ Regenerate' : '💳 Generate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1" id="stripe-link-info">
|
||||||
|
${existingStripeUrl
|
||||||
|
? 'Link exists. Regenerate will create a new link for the current balance.'
|
||||||
|
: 'Generates a Stripe Payment Link for Card and ACH payments.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Message Body</label>
|
||||||
|
<div id="email-message-editor" class="border border-gray-300 rounded-md bg-white h-48 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 border border-blue-200 p-4 rounded-md flex items-center gap-3">
|
||||||
|
<span class="text-2xl">📎</span>
|
||||||
|
<div class="text-sm text-blue-800">
|
||||||
|
<strong>Invoice_${currentInvoice.invoice_number || currentInvoice.id}.pdf</strong> will be generated and attached automatically.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button type="button" onclick="window.emailModal.close()"
|
||||||
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
|
<button type="submit" id="email-submit-btn"
|
||||||
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-semibold">
|
||||||
|
Send via AWS SES
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Initialize Quill
|
||||||
|
const editorDiv = document.getElementById('email-message-editor');
|
||||||
|
quillInstance = new Quill(editorDiv, {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Variablen für den Text aufbereiten
|
||||||
|
const invoiceNum = currentInvoice.invoice_number || currentInvoice.id;
|
||||||
|
const totalDue = parseFloat(currentInvoice.balance ?? currentInvoice.total).toFixed(2);
|
||||||
|
|
||||||
|
// Datum formatieren
|
||||||
|
let dueDateStr = 'Upon Receipt';
|
||||||
|
if (currentInvoice.due_date) {
|
||||||
|
const d = new Date(currentInvoice.due_date);
|
||||||
|
dueDateStr = d.toLocaleDateString('en-US', { timeZone: 'UTC' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamischer Text für die Fälligkeit
|
||||||
|
let paymentText = '';
|
||||||
|
if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) {
|
||||||
|
paymentText = 'Our terms are Net 30.';
|
||||||
|
} else if (dueDateStr !== 'Upon Receipt') {
|
||||||
|
paymentText = `payable by <strong>${dueDateStr}</strong>.`;
|
||||||
|
} else {
|
||||||
|
paymentText = 'Our terms are Net 30.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultHtml = `
|
||||||
|
<p>Good afternoon,</p>
|
||||||
|
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>. ${paymentText}</p>
|
||||||
|
<p>Please pay at your earliest convenience. We appreciate your continued business.</p>
|
||||||
|
<p>If you have any questions about the invoice, feel free to reply to this email.</p>
|
||||||
|
<p>Best regards,</p>
|
||||||
|
<p><strong>Claudia Knuth</strong></p>
|
||||||
|
<p>Bay Area Affiliates, Inc.</p>
|
||||||
|
<p>accounting@bayarea-cc.com</p>
|
||||||
|
`;
|
||||||
|
quillInstance.root.innerHTML = defaultHtml;
|
||||||
|
|
||||||
|
// Bind Submit Handler
|
||||||
|
document.getElementById('email-send-form').addEventListener('submit', submitEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stripe Payment Link Generation
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function generateStripeLink() {
|
||||||
|
const btn = document.getElementById('stripe-generate-btn');
|
||||||
|
const input = document.getElementById('email-stripe-link');
|
||||||
|
const info = document.getElementById('stripe-link-info');
|
||||||
|
|
||||||
|
const originalBtnText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳ Creating...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${currentInvoice.id}/create-payment-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
input.value = result.paymentLinkUrl;
|
||||||
|
currentInvoice.stripe_payment_link_url = result.paymentLinkUrl;
|
||||||
|
currentInvoice.stripe_payment_link_id = result.paymentLinkId;
|
||||||
|
currentInvoice.stripe_payment_status = 'pending';
|
||||||
|
|
||||||
|
btn.innerHTML = '♻️ Regenerate';
|
||||||
|
info.innerHTML = `✅ Payment link created for <strong>$${result.amount.toFixed(2)}</strong>. Will be included in the email.`;
|
||||||
|
info.classList.remove('text-gray-400');
|
||||||
|
info.classList.add('text-green-600');
|
||||||
|
} else {
|
||||||
|
info.textContent = `❌ ${result.error}`;
|
||||||
|
info.classList.remove('text-gray-400');
|
||||||
|
info.classList.add('text-red-500');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Stripe link generation error:', e);
|
||||||
|
info.textContent = '❌ Network error creating payment link.';
|
||||||
|
info.classList.remove('text-gray-400');
|
||||||
|
info.classList.add('text-red-500');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (btn.innerHTML === '⏳ Creating...') {
|
||||||
|
btn.innerHTML = originalBtnText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Logic & API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function openEmailModal(invoiceId) {
|
||||||
|
ensureModalElement();
|
||||||
|
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Loading invoice data...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${invoiceId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.invoice) throw new Error('Invoice not found');
|
||||||
|
currentInvoice = data.invoice;
|
||||||
|
|
||||||
|
renderModalContent();
|
||||||
|
|
||||||
|
document.getElementById('email-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('email-modal').classList.add('flex');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading invoice for email:', e);
|
||||||
|
alert('Could not load invoice details.');
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeEmailModal() {
|
||||||
|
const modal = document.getElementById('email-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
currentInvoice = null;
|
||||||
|
quillInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEmail(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const recipientEmail = document.getElementById('email-recipient').value.trim();
|
||||||
|
const customText = quillInstance.root.innerHTML;
|
||||||
|
|
||||||
|
if (!recipientEmail) {
|
||||||
|
alert('Please enter a recipient email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('email-submit-btn');
|
||||||
|
submitBtn.innerHTML = '⏳ Sending...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Generating PDF and sending email...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${currentInvoice.id}/send-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipientEmail,
|
||||||
|
customText
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('✅ Invoice sent successfully!');
|
||||||
|
closeEmailModal();
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert(`❌ Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Send email error:', e);
|
||||||
|
alert('Network error while sending email.');
|
||||||
|
} finally {
|
||||||
|
submitBtn.innerHTML = 'Send via AWS SES';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose
|
||||||
|
// ============================================================
|
||||||
|
window.emailModal = {
|
||||||
|
open: openEmailModal,
|
||||||
|
close: closeEmailModal,
|
||||||
|
generateStripeLink
|
||||||
|
};
|
||||||
233
public/js/modals/invoice-modal.js
Normal file
233
public/js/modals/invoice-modal.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* invoice-modal.js — Invoice create/edit modal
|
||||||
|
* Uses shared item-editor for accordion items
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Auto-sets tax-exempt based on customer's taxable flag
|
||||||
|
* - Recurring invoice support (monthly/yearly)
|
||||||
|
*/
|
||||||
|
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||||
|
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let currentInvoiceId = null;
|
||||||
|
let qboLaborRate = null;
|
||||||
|
|
||||||
|
export async function loadLaborRate() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/labor-rate');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.rate) {
|
||||||
|
qboLaborRate = data.rate;
|
||||||
|
console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Labor Rate konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLaborRate() { return qboLaborRate; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-set tax exempt based on customer's taxable flag
|
||||||
|
*/
|
||||||
|
function applyCustomerTaxStatus(customerId) {
|
||||||
|
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||||
|
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||||||
|
if (customer) {
|
||||||
|
const cb = document.getElementById('invoice-tax-exempt');
|
||||||
|
if (cb) {
|
||||||
|
cb.checked = (customer.taxable === false);
|
||||||
|
updateInvoiceTotals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openInvoiceModal(invoiceId = null) {
|
||||||
|
currentInvoiceId = invoiceId;
|
||||||
|
if (invoiceId) {
|
||||||
|
await loadInvoiceForEdit(invoiceId);
|
||||||
|
} else {
|
||||||
|
prepareNewInvoice();
|
||||||
|
}
|
||||||
|
document.getElementById('invoice-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeInvoiceModal() {
|
||||||
|
document.getElementById('invoice-modal').classList.remove('active');
|
||||||
|
currentInvoiceId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvoiceForEdit(invoiceId) {
|
||||||
|
document.getElementById('invoice-modal-title').textContent = 'Edit Invoice';
|
||||||
|
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Set customer in Alpine component
|
||||||
|
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||||
|
const customer = allCust.find(c => c.id === data.invoice.customer_id);
|
||||||
|
if (customer) {
|
||||||
|
const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]');
|
||||||
|
if (customerInput) {
|
||||||
|
customerInput.value = customer.name;
|
||||||
|
customerInput.dispatchEvent(new Event('input'));
|
||||||
|
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||||
|
if (alpineData) {
|
||||||
|
alpineData.search = customer.name;
|
||||||
|
alpineData.selectedId = customer.id;
|
||||||
|
alpineData.selectedName = customer.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
|
||||||
|
document.getElementById('invoice-customer').value = data.invoice.customer_id;
|
||||||
|
document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0];
|
||||||
|
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;
|
||||||
|
document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || '';
|
||||||
|
|
||||||
|
const sendDateEl = document.getElementById('invoice-send-date');
|
||||||
|
if (sendDateEl) {
|
||||||
|
sendDateEl.value = data.invoice.scheduled_send_date
|
||||||
|
? data.invoice.scheduled_send_date.split('T')[0] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring fields
|
||||||
|
const recurringCb = document.getElementById('invoice-recurring');
|
||||||
|
const recurringInterval = document.getElementById('invoice-recurring-interval');
|
||||||
|
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||||
|
if (recurringCb) {
|
||||||
|
recurringCb.checked = data.invoice.is_recurring || false;
|
||||||
|
if (recurringInterval) recurringInterval.value = data.invoice.recurring_interval || 'monthly';
|
||||||
|
if (recurringGroup) recurringGroup.style.display = data.invoice.is_recurring ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load items
|
||||||
|
document.getElementById('invoice-items').innerHTML = '';
|
||||||
|
resetItemCounter();
|
||||||
|
data.items.forEach(item => {
|
||||||
|
addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||||
|
});
|
||||||
|
updateInvoiceTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareNewInvoice() {
|
||||||
|
document.getElementById('invoice-modal-title').textContent = 'New Invoice';
|
||||||
|
document.getElementById('invoice-form').reset();
|
||||||
|
document.getElementById('invoice-items').innerHTML = '';
|
||||||
|
document.getElementById('invoice-terms').value = 'Net 30';
|
||||||
|
document.getElementById('invoice-number').value = '';
|
||||||
|
document.getElementById('invoice-send-date').value = '';
|
||||||
|
|
||||||
|
// Reset recurring
|
||||||
|
const recurringCb = document.getElementById('invoice-recurring');
|
||||||
|
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||||
|
if (recurringCb) recurringCb.checked = false;
|
||||||
|
if (recurringGroup) recurringGroup.style.display = 'none';
|
||||||
|
|
||||||
|
resetItemCounter();
|
||||||
|
setDefaultDate();
|
||||||
|
addItem('invoice-items', { type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInvoiceItem(item = null) {
|
||||||
|
addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInvoiceTotals() {
|
||||||
|
const items = getItems('invoice-items');
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleInvoiceSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const isRecurring = document.getElementById('invoice-recurring')?.checked || false;
|
||||||
|
const recurringInterval = isRecurring
|
||||||
|
? (document.getElementById('invoice-recurring-interval')?.value || 'monthly') : null;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
invoice_number: document.getElementById('invoice-number').value || null,
|
||||||
|
customer_id: 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,
|
||||||
|
scheduled_send_date: document.getElementById('invoice-send-date')?.value || null,
|
||||||
|
bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null,
|
||||||
|
is_recurring: isRecurring,
|
||||||
|
recurring_interval: recurringInterval,
|
||||||
|
items: getItems('invoice-items')
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.customer_id) { alert('Please select a customer.'); return; }
|
||||||
|
if (!data.items || data.items.length === 0) { alert('Please add at least one item.'); return; }
|
||||||
|
|
||||||
|
const invoiceId = currentInvoiceId;
|
||||||
|
const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
|
||||||
|
const method = invoiceId ? 'PUT' : 'POST';
|
||||||
|
showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
closeInvoiceModal();
|
||||||
|
if (result.qbo_doc_number) console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
|
||||||
|
else if (result.qbo_synced) console.log('✅ Invoice saved & synced to QBO');
|
||||||
|
else console.log('✅ Invoice saved locally (QBO sync pending)');
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error saving invoice');
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initInvoiceModal() {
|
||||||
|
const form = document.getElementById('invoice-form');
|
||||||
|
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||||||
|
|
||||||
|
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||||||
|
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||||||
|
|
||||||
|
// Recurring toggle
|
||||||
|
const recurringCb = document.getElementById('invoice-recurring');
|
||||||
|
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||||
|
if (recurringCb && recurringGroup) {
|
||||||
|
recurringCb.addEventListener('change', () => {
|
||||||
|
recurringGroup.style.display = recurringCb.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for customer selection → auto-set tax exempt (only for new invoices)
|
||||||
|
const customerHidden = document.getElementById('invoice-customer');
|
||||||
|
if (customerHidden) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Only auto-apply when creating new (not editing existing)
|
||||||
|
if (!currentInvoiceId && customerHidden.value) {
|
||||||
|
applyCustomerTaxStatus(customerHidden.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openInvoiceModal = openInvoiceModal;
|
||||||
|
window.closeInvoiceModal = closeInvoiceModal;
|
||||||
|
window.addInvoiceItem = addInvoiceItem;
|
||||||
364
public/js/modals/payment-modal.js
Normal file
364
public/js/modals/payment-modal.js
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
// payment-modal.js — ES Module v3 (clean)
|
||||||
|
// Invoice payments: multi-invoice, partial, overpay
|
||||||
|
// No downpayment functionality
|
||||||
|
|
||||||
|
let bankAccounts = [];
|
||||||
|
let paymentMethods = [];
|
||||||
|
let selectedInvoices = []; // { invoice, payAmount }
|
||||||
|
let dataLoaded = false;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Load QBO Data
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function loadQboData() {
|
||||||
|
if (dataLoaded) return;
|
||||||
|
try {
|
||||||
|
const [accRes, pmRes] = await Promise.all([
|
||||||
|
fetch('/api/qbo/accounts'),
|
||||||
|
fetch('/api/qbo/payment-methods')
|
||||||
|
]);
|
||||||
|
if (accRes.ok) bankAccounts = await accRes.json();
|
||||||
|
if (pmRes.ok) paymentMethods = await pmRes.json();
|
||||||
|
dataLoaded = true;
|
||||||
|
} catch (e) { console.error('Error loading QBO data:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Open / Close
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function openPaymentModal(invoiceIds = []) {
|
||||||
|
await loadQboData();
|
||||||
|
selectedInvoices = [];
|
||||||
|
|
||||||
|
for (const id of invoiceIds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.invoice) {
|
||||||
|
const total = parseFloat(data.invoice.total);
|
||||||
|
const amountPaid = parseFloat(data.invoice.amount_paid) || 0;
|
||||||
|
const balance = total - amountPaid;
|
||||||
|
selectedInvoices.push({
|
||||||
|
invoice: data.invoice,
|
||||||
|
payAmount: balance > 0 ? balance : total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureModalElement();
|
||||||
|
renderModalContent();
|
||||||
|
document.getElementById('payment-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePaymentModal() {
|
||||||
|
const modal = document.getElementById('payment-modal');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
selectedInvoices = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Add / Remove Invoices
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function addInvoiceById() {
|
||||||
|
const input = document.getElementById('payment-add-invoice-id');
|
||||||
|
const searchVal = input.value.trim();
|
||||||
|
if (!searchVal) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/invoices');
|
||||||
|
const allInvoices = await res.json();
|
||||||
|
const match = allInvoices.find(inv =>
|
||||||
|
String(inv.id) === searchVal || String(inv.invoice_number) === searchVal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; }
|
||||||
|
if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; }
|
||||||
|
if (match.paid_date) { alert('This invoice is already paid.'); return; }
|
||||||
|
if (selectedInvoices.find(si => si.invoice.id === match.id)) { alert('Invoice already in list.'); return; }
|
||||||
|
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||||||
|
alert('All invoices must belong to the same customer.'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
||||||
|
const detailData = await detailRes.json();
|
||||||
|
const detailInv = detailData.invoice;
|
||||||
|
const detailTotal = parseFloat(detailInv.total);
|
||||||
|
const detailPaid = parseFloat(detailInv.amount_paid) || 0;
|
||||||
|
const detailBalance = detailTotal - detailPaid;
|
||||||
|
selectedInvoices.push({
|
||||||
|
invoice: detailInv,
|
||||||
|
payAmount: detailBalance > 0 ? detailBalance : detailTotal
|
||||||
|
});
|
||||||
|
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
input.value = '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error adding invoice:', e);
|
||||||
|
alert('Error searching for invoice.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInvoice(invoiceId) {
|
||||||
|
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePayAmount(invoiceId, newAmount) {
|
||||||
|
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
||||||
|
if (si) {
|
||||||
|
si.payAmount = Math.max(0, parseFloat(newAmount) || 0);
|
||||||
|
}
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DOM
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function ensureModalElement() {
|
||||||
|
let modal = document.getElementById('payment-modal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'payment-modal';
|
||||||
|
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModalContent() {
|
||||||
|
const modal = document.getElementById('payment-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
||||||
|
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
|
||||||
|
const methods = filtered.length > 0 ? filtered : paymentMethods;
|
||||||
|
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
||||||
|
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invoice List -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
||||||
|
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<input type="text" id="payment-add-invoice-id" placeholder="Add by Invoice # or ID..."
|
||||||
|
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
|
||||||
|
<button onclick="window.paymentModal.addById()"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">+ Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Details -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
|
||||||
|
<input type="date" id="payment-date" value="${today}"
|
||||||
|
class="w-full px-3 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">Reference # (Check / ACH)</label>
|
||||||
|
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
|
||||||
|
class="w-full px-3 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">Payment Method</label>
|
||||||
|
<select id="payment-method"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
${methodOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
|
||||||
|
<select id="payment-deposit-to"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
${accountOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
|
||||||
|
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button onclick="window.paymentModal.close()"
|
||||||
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
|
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
|
||||||
|
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
|
||||||
|
💰 Record Payment in QBO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceList() {
|
||||||
|
const container = document.getElementById('payment-invoice-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (selectedInvoices.length === 0) {
|
||||||
|
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">No invoices selected — add below</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = selectedInvoices.map(si => {
|
||||||
|
const inv = si.invoice;
|
||||||
|
const total = parseFloat(inv.total);
|
||||||
|
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||||
|
const balance = total - amountPaid;
|
||||||
|
const isPartial = si.payAmount < balance;
|
||||||
|
const isOver = si.payAmount > balance;
|
||||||
|
|
||||||
|
const paidInfo = amountPaid > 0
|
||||||
|
? `<span class="text-green-600 text-xs ml-1">Paid: $${amountPaid.toFixed(2)}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
|
||||||
|
<span class="text-gray-500 text-sm ml-2 truncate">${inv.customer_name || ''}</span>
|
||||||
|
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
|
||||||
|
${paidInfo}
|
||||||
|
${isPartial ? '<span class="text-xs text-yellow-600 ml-1 font-semibold">Partial</span>' : ''}
|
||||||
|
${isOver ? '<span class="text-xs text-blue-600 ml-1 font-semibold">Overpay</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span class="text-gray-500 text-sm">$</span>
|
||||||
|
<input type="number" step="0.01" min="0.01"
|
||||||
|
value="${si.payAmount.toFixed(2)}"
|
||||||
|
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
|
||||||
|
class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold
|
||||||
|
${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
|
||||||
|
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||||
|
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotal() {
|
||||||
|
const totalEl = document.getElementById('payment-total');
|
||||||
|
const noteEl = document.getElementById('payment-overpay-note');
|
||||||
|
if (!totalEl) return;
|
||||||
|
|
||||||
|
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||||
|
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||||
|
totalEl.textContent = `$${payTotal.toFixed(2)}`;
|
||||||
|
|
||||||
|
if (noteEl) {
|
||||||
|
if (payTotal > invTotal && invTotal > 0) {
|
||||||
|
noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
|
||||||
|
noteEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
noteEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Submit
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function submitPayment() {
|
||||||
|
if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; }
|
||||||
|
|
||||||
|
const paymentDate = document.getElementById('payment-date').value;
|
||||||
|
const reference = document.getElementById('payment-reference').value;
|
||||||
|
const methodSelect = document.getElementById('payment-method');
|
||||||
|
const depositSelect = document.getElementById('payment-deposit-to');
|
||||||
|
|
||||||
|
if (!paymentDate || !methodSelect.value || !depositSelect.value) {
|
||||||
|
alert('Please fill in all fields.'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||||
|
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||||
|
const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
|
||||||
|
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
||||||
|
const hasOverpay = total > invTotal;
|
||||||
|
|
||||||
|
let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`;
|
||||||
|
if (hasPartial) msg += '\n⚠️ Contains partial payment(s).';
|
||||||
|
if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('payment-submit-btn');
|
||||||
|
submitBtn.innerHTML = '⏳ Processing...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Recording payment in QBO...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/record-payment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
invoice_payments: selectedInvoices.map(si => ({
|
||||||
|
invoice_id: si.invoice.id,
|
||||||
|
amount: si.payAmount
|
||||||
|
})),
|
||||||
|
payment_date: paymentDate,
|
||||||
|
reference_number: reference,
|
||||||
|
payment_method_id: methodSelect.value,
|
||||||
|
payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '',
|
||||||
|
deposit_to_account_id: depositSelect.value,
|
||||||
|
deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || ''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ ${result.message}`);
|
||||||
|
closePaymentModal();
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert(`❌ Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Payment error:', e);
|
||||||
|
alert('Network error.');
|
||||||
|
} finally {
|
||||||
|
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.paymentModal = {
|
||||||
|
open: openPaymentModal,
|
||||||
|
close: closePaymentModal,
|
||||||
|
submit: submitPayment,
|
||||||
|
addById: addInvoiceById,
|
||||||
|
removeInvoice: removeInvoice,
|
||||||
|
updateAmount: updatePayAmount,
|
||||||
|
updateTotal: updateTotal
|
||||||
|
};
|
||||||
180
public/js/modals/quote-modal.js
Normal file
180
public/js/modals/quote-modal.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* quote-modal.js — Quote create/edit modal
|
||||||
|
* Uses shared item-editor for accordion items
|
||||||
|
*/
|
||||||
|
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||||
|
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let currentQuoteId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-set tax exempt based on customer's taxable flag
|
||||||
|
*/
|
||||||
|
function applyCustomerTaxStatus(customerId) {
|
||||||
|
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||||
|
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||||||
|
if (customer) {
|
||||||
|
const cb = document.getElementById('quote-tax-exempt');
|
||||||
|
if (cb) {
|
||||||
|
cb.checked = (customer.taxable === false);
|
||||||
|
updateQuoteTotals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openQuoteModal(quoteId = null) {
|
||||||
|
currentQuoteId = quoteId;
|
||||||
|
|
||||||
|
if (quoteId) {
|
||||||
|
loadQuoteForEdit(quoteId);
|
||||||
|
} else {
|
||||||
|
prepareNewQuote();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('quote-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeQuoteModal() {
|
||||||
|
document.getElementById('quote-modal').classList.remove('active');
|
||||||
|
currentQuoteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuoteForEdit(quoteId) {
|
||||||
|
document.getElementById('quote-modal-title').textContent = 'Edit Quote';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/quotes/${quoteId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Set customer in Alpine component
|
||||||
|
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||||
|
const customer = allCust.find(c => c.id === data.quote.customer_id);
|
||||||
|
if (customer) {
|
||||||
|
const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]');
|
||||||
|
if (customerInput) {
|
||||||
|
customerInput.value = customer.name;
|
||||||
|
customerInput.dispatchEvent(new Event('input'));
|
||||||
|
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||||
|
if (alpineData) {
|
||||||
|
alpineData.search = customer.name;
|
||||||
|
alpineData.selectedId = customer.id;
|
||||||
|
alpineData.selectedName = customer.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('quote-customer').value = data.quote.customer_id;
|
||||||
|
document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0];
|
||||||
|
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||||
|
|
||||||
|
// Load items using shared editor
|
||||||
|
document.getElementById('quote-items').innerHTML = '';
|
||||||
|
resetItemCounter();
|
||||||
|
data.items.forEach(item => {
|
||||||
|
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||||
|
});
|
||||||
|
|
||||||
|
updateQuoteTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareNewQuote() {
|
||||||
|
document.getElementById('quote-modal-title').textContent = 'New Quote';
|
||||||
|
document.getElementById('quote-form').reset();
|
||||||
|
document.getElementById('quote-items').innerHTML = '';
|
||||||
|
resetItemCounter();
|
||||||
|
setDefaultDate();
|
||||||
|
|
||||||
|
// Add one default item
|
||||||
|
addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addQuoteItem(item = null) {
|
||||||
|
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateQuoteTotals() {
|
||||||
|
const items = getItems('quote-items');
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleQuoteSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const items = getItems('quote-items');
|
||||||
|
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();
|
||||||
|
if (window.quoteView) window.quoteView.loadQuotes();
|
||||||
|
} else {
|
||||||
|
alert('Error saving quote');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error saving quote');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up form submit and tax-exempt checkbox
|
||||||
|
export function initQuoteModal() {
|
||||||
|
const form = document.getElementById('quote-form');
|
||||||
|
if (form) form.addEventListener('submit', handleQuoteSubmit);
|
||||||
|
|
||||||
|
const taxExempt = document.getElementById('quote-tax-exempt');
|
||||||
|
if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals);
|
||||||
|
|
||||||
|
// Watch for customer selection → auto-set tax exempt (only for new quotes)
|
||||||
|
const customerHidden = document.getElementById('quote-customer');
|
||||||
|
if (customerHidden) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (!currentQuoteId && customerHidden.value) {
|
||||||
|
applyCustomerTaxStatus(customerHidden.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.openQuoteModal = openQuoteModal;
|
||||||
|
window.closeQuoteModal = closeQuoteModal;
|
||||||
|
window.addQuoteItem = addQuoteItem;
|
||||||
129
public/js/utils/api.js
Normal file
129
public/js/utils/api.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* API Utility
|
||||||
|
* Centralized API calls for the frontend
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
// Customer API
|
||||||
|
customers: {
|
||||||
|
getAll: () => fetch('/api/customers').then(r => r.json()),
|
||||||
|
get: (id) => fetch(`/api/customers/${id}`).then(r => r.json()),
|
||||||
|
create: (data) => fetch('/api/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
update: (id, data) => fetch(`/api/customers/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
delete: (id) => fetch(`/api/customers/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||||
|
exportToQbo: (id) => fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' }).then(r => r.json())
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quote API
|
||||||
|
quotes: {
|
||||||
|
getAll: () => fetch('/api/quotes').then(r => r.json()),
|
||||||
|
get: (id) => fetch(`/api/quotes/${id}`).then(r => r.json()),
|
||||||
|
create: (data) => fetch('/api/quotes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
update: (id, data) => fetch(`/api/quotes/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
delete: (id) => fetch(`/api/quotes/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||||
|
convertToInvoice: (id) => fetch(`/api/quotes/${id}/convert-to-invoice`, { method: 'POST' }).then(r => r.json()),
|
||||||
|
getPdf: (id) => window.open(`/api/quotes/${id}/pdf`, '_blank'),
|
||||||
|
getHtml: (id) => window.open(`/api/quotes/${id}/html`, '_blank')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invoice API
|
||||||
|
invoices: {
|
||||||
|
getAll: () => fetch('/api/invoices').then(r => r.json()),
|
||||||
|
get: (id) => fetch(`/api/invoices/${id}`).then(r => r.json()),
|
||||||
|
getNextNumber: () => fetch('/api/invoices/next-number').then(r => r.json()),
|
||||||
|
create: (data) => fetch('/api/invoices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
update: (id, data) => fetch(`/api/invoices/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
delete: (id) => fetch(`/api/invoices/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||||
|
exportToQbo: (id) => fetch(`/api/invoices/${id}/export`, { method: 'POST' }).then(r => r.json()),
|
||||||
|
updateQbo: (id) => fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' }).then(r => r.json()),
|
||||||
|
markPaid: (id, paidDate) => fetch(`/api/invoices/${id}/mark-paid`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paid_date: paidDate })
|
||||||
|
}).then(r => r.json()),
|
||||||
|
markUnpaid: (id) => fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }).then(r => r.json()),
|
||||||
|
resetQbo: (id) => fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }).then(r => r.json()),
|
||||||
|
setEmailStatus: (id, status) => fetch(`/api/invoices/${id}/email-status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
}).then(r => r.json()),
|
||||||
|
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
|
||||||
|
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Payment API
|
||||||
|
payments: {
|
||||||
|
getAll: () => fetch('/api/payments').then(r => r.json()),
|
||||||
|
record: (data) => fetch('/api/qbo/record-payment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json())
|
||||||
|
},
|
||||||
|
|
||||||
|
// NEU: Stripe API
|
||||||
|
stripe: {
|
||||||
|
createPaymentLink: (invoiceId) => fetch(`/api/invoices/${invoiceId}/create-payment-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).then(r => r.json()),
|
||||||
|
checkPayment: (invoiceId) => fetch(`/api/invoices/${invoiceId}/check-payment`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).then(r => r.json())
|
||||||
|
},
|
||||||
|
|
||||||
|
// QBO API
|
||||||
|
qbo: {
|
||||||
|
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
||||||
|
getAccounts: () => fetch('/api/qbo/accounts').then(r => r.json()),
|
||||||
|
getPaymentMethods: () => fetch('/api/qbo/payment-methods').then(r => r.json()),
|
||||||
|
getLaborRate: () => fetch('/api/qbo/labor-rate').then(r => r.json()),
|
||||||
|
getLastSync: () => fetch('/api/qbo/last-sync').then(r => r.json()),
|
||||||
|
getOverdue: () => fetch('/api/qbo/overdue').then(r => r.json()),
|
||||||
|
importUnpaid: () => fetch('/api/qbo/import-unpaid', { method: 'POST' }).then(r => r.json()),
|
||||||
|
syncPayments: () => fetch('/api/qbo/sync-payments', { method: 'POST' }).then(r => r.json()),
|
||||||
|
auth: () => window.location.href = '/auth/qbo'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings API
|
||||||
|
settings: {
|
||||||
|
getLogo: () => fetch('/api/logo-info').then(r => r.json()),
|
||||||
|
uploadLogo: (file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('logo', file);
|
||||||
|
return fetch('/api/upload-logo', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.API = API;
|
||||||
48
public/js/utils/helpers.js
Normal file
48
public/js/utils/helpers.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* helpers.js — Shared UI utility functions
|
||||||
|
* Extracted from app.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
export 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSpinner(message = 'Bitte warten...') {
|
||||||
|
let overlay = document.getElementById('qbo-spinner');
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'qbo-spinner';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl px-8 py-6 flex items-center gap-4">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-lg font-medium text-gray-700" id="qbo-spinner-text">${message}</span>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideSpinner() {
|
||||||
|
const overlay = document.getElementById('qbo-spinner');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep backward compat for onclick handlers and modules using typeof check
|
||||||
|
window.showSpinner = showSpinner;
|
||||||
|
window.hideSpinner = hideSpinner;
|
||||||
293
public/js/utils/item-editor.js
Normal file
293
public/js/utils/item-editor.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* item-editor.js — Shared accordion item editor for Quotes and Invoices
|
||||||
|
*
|
||||||
|
* Replaces the duplicated addQuoteItem/addInvoiceItem logic (~300 lines → 1 function).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { addItem, getItems, removeItem, moveItemUp, moveItemDown, updateTotals } from './item-editor.js';
|
||||||
|
* addItem('quote-items', { item: existingItem, type: 'quote', laborRate: 125 });
|
||||||
|
*/
|
||||||
|
|
||||||
|
let itemCounter = 0;
|
||||||
|
|
||||||
|
export function resetItemCounter() {
|
||||||
|
itemCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemCounter() {
|
||||||
|
return itemCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item row to the specified container.
|
||||||
|
*
|
||||||
|
* @param {string} containerId - DOM id of the items container ('quote-items' or 'invoice-items')
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object|null} options.item - Existing item data (null for new empty item)
|
||||||
|
* @param {string} options.type - 'quote' or 'invoice'
|
||||||
|
* @param {number|null} options.laborRate - QBO labor rate for auto-fill (invoice only)
|
||||||
|
* @param {function} options.onUpdate - Callback after any change (for recalculating totals)
|
||||||
|
*/
|
||||||
|
export function addItem(containerId, { item = null, type = 'invoice', laborRate = null, onUpdate = () => {} } = {}) {
|
||||||
|
const itemId = itemCounter++;
|
||||||
|
const itemsDiv = document.getElementById(containerId);
|
||||||
|
if (!itemsDiv) return;
|
||||||
|
|
||||||
|
const prefix = type; // 'quote' or 'invoice'
|
||||||
|
const cssClass = `${prefix}-item-input`;
|
||||||
|
const editorClass = `${prefix}-item-description-editor`;
|
||||||
|
const amountClass = `${prefix}-item-amount`;
|
||||||
|
|
||||||
|
// Preview defaults
|
||||||
|
const previewQty = item ? item.quantity : '';
|
||||||
|
const previewAmount = item ? item.amount : '$0.00';
|
||||||
|
let previewDesc = 'New item';
|
||||||
|
if (item && item.description) {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = item.description;
|
||||||
|
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||||
|
}
|
||||||
|
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||||
|
|
||||||
|
const itemDiv = document.createElement('div');
|
||||||
|
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||||
|
itemDiv.id = `${prefix}-item-${itemId}`;
|
||||||
|
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||||
|
|
||||||
|
itemDiv.innerHTML = `
|
||||||
|
<div class="flex items-center p-4">
|
||||||
|
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" onclick="window.itemEditor.moveUp('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||||
|
<button type="button" onclick="window.itemEditor.moveDown('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||||
|
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||||
|
|
||||||
|
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||||
|
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||||
|
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onclick="window.itemEditor.remove('${prefix}', ${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||||
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
|
<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="${cssClass} 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">Type (Internal)</label>
|
||||||
|
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="window.itemEditor.handleTypeChange(this, '${prefix}', ${itemId})">
|
||||||
|
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||||
|
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<div data-item="${itemId}" data-field="description" class="${editorClass} 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="${cssClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="col-span-3">
|
||||||
|
<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="${amountClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
itemsDiv.appendChild(itemDiv);
|
||||||
|
|
||||||
|
// --- Quill Rich Text Editor ---
|
||||||
|
const editorDiv = itemDiv.querySelector(`.${editorClass}`);
|
||||||
|
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', () => {
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
// Quote supports TBD
|
||||||
|
if (type === 'quote' && rateInput.value.toUpperCase() === 'TBD') {
|
||||||
|
// Don't auto-calculate for TBD
|
||||||
|
} else {
|
||||||
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
amountInput.value = (qty * rateValue).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
qtyInput.addEventListener('input', calculateAmount);
|
||||||
|
rateInput.addEventListener('input', calculateAmount);
|
||||||
|
amountInput.addEventListener('input', () => {
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store metadata on the div for later retrieval
|
||||||
|
itemDiv._itemEditor = { type, laborRate, onUpdate };
|
||||||
|
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the collapsed preview bar of an item
|
||||||
|
*/
|
||||||
|
function updateItemPreview(itemDiv) {
|
||||||
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]');
|
||||||
|
const editorDivs = itemDiv.querySelectorAll('[data-field="description"]');
|
||||||
|
const editorDiv = editorDivs.length > 0 ? editorDivs[0] : null;
|
||||||
|
|
||||||
|
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||||
|
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||||
|
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||||
|
const typePreview = itemDiv.querySelector('.item-type-preview');
|
||||||
|
|
||||||
|
if (qtyPreview && qtyInput) qtyPreview.textContent = qtyInput.value || '0';
|
||||||
|
if (amountPreview && amountInput) amountPreview.textContent = amountInput.value || '$0.00';
|
||||||
|
|
||||||
|
if (typePreview && typeInput) {
|
||||||
|
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descPreview && editorDiv && editorDiv.quillInstance) {
|
||||||
|
const plainText = editorDiv.quillInstance.getText().trim();
|
||||||
|
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||||
|
descPreview.textContent = preview || 'New item';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle type change (Labor/Parts).
|
||||||
|
* When Labor is selected and rate is empty, auto-fill with labor rate.
|
||||||
|
*/
|
||||||
|
export function handleTypeChange(selectEl, prefix, itemId) {
|
||||||
|
const itemDiv = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!itemDiv) return;
|
||||||
|
|
||||||
|
const meta = itemDiv._itemEditor || {};
|
||||||
|
const laborRate = meta.laborRate;
|
||||||
|
const onUpdate = meta.onUpdate || (() => {});
|
||||||
|
|
||||||
|
// Auto-fill labor rate when switching to Labor and rate is empty
|
||||||
|
if (selectEl.value === '5' && laborRate) {
|
||||||
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
|
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
|
||||||
|
rateInput.value = laborRate;
|
||||||
|
// Recalculate amount
|
||||||
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
if (qtyInput.value) {
|
||||||
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
amountInput.value = (qty * laborRate).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all items from a container as an array of objects.
|
||||||
|
*/
|
||||||
|
export function getItems(containerId) {
|
||||||
|
const items = [];
|
||||||
|
const itemDivs = document.querySelectorAll(`#${containerId} > div`);
|
||||||
|
|
||||||
|
itemDivs.forEach(div => {
|
||||||
|
const descEditor = div.querySelector('[data-field="description"]');
|
||||||
|
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||||
|
? descEditor.quillInstance.root.innerHTML
|
||||||
|
: '';
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||||
|
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||||
|
description: descriptionHTML,
|
||||||
|
rate: div.querySelector('[data-field="rate"]').value,
|
||||||
|
amount: div.querySelector('[data-field="amount"]').value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item by prefix and itemId
|
||||||
|
*/
|
||||||
|
export function removeItem(prefix, itemId) {
|
||||||
|
const el = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!el) return;
|
||||||
|
const meta = el._itemEditor || {};
|
||||||
|
el.remove();
|
||||||
|
if (meta.onUpdate) meta.onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item up
|
||||||
|
*/
|
||||||
|
export function moveItemUp(prefix, itemId) {
|
||||||
|
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!item) return;
|
||||||
|
const prevItem = item.previousElementSibling;
|
||||||
|
if (prevItem) {
|
||||||
|
item.parentNode.insertBefore(item, prevItem);
|
||||||
|
const meta = item._itemEditor || {};
|
||||||
|
if (meta.onUpdate) meta.onUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item down
|
||||||
|
*/
|
||||||
|
export function moveItemDown(prefix, itemId) {
|
||||||
|
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!item) return;
|
||||||
|
const nextItem = item.nextElementSibling;
|
||||||
|
if (nextItem) {
|
||||||
|
item.parentNode.insertBefore(nextItem, item);
|
||||||
|
const meta = item._itemEditor || {};
|
||||||
|
if (meta.onUpdate) meta.onUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose to window for onclick handlers in HTML
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.itemEditor = {
|
||||||
|
moveUp: moveItemUp,
|
||||||
|
moveDown: moveItemDown,
|
||||||
|
remove: removeItem,
|
||||||
|
handleTypeChange: handleTypeChange
|
||||||
|
};
|
||||||
405
public/js/views/customer-view.js
Normal file
405
public/js/views/customer-view.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
// customer-view.js — ES Module
|
||||||
|
// Customer list with filtering, QBO status, email, modal with contact/remarks
|
||||||
|
|
||||||
|
let customers = [];
|
||||||
|
let filterName = localStorage.getItem('cust_filterName') || '';
|
||||||
|
let filterQbo = localStorage.getItem('cust_filterQbo') || 'all'; // all | qbo | local
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Data
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/customers');
|
||||||
|
customers = await response.json();
|
||||||
|
// Backward compat: quote/invoice modals use global 'customers' variable
|
||||||
|
window.customers = customers;
|
||||||
|
renderCustomerView();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading customers:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomers() { return customers; }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Filter
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function getFilteredCustomers() {
|
||||||
|
let f = [...customers];
|
||||||
|
if (filterName.trim()) {
|
||||||
|
const s = filterName.toLowerCase();
|
||||||
|
f = f.filter(c => (c.name || '').toLowerCase().includes(s) ||
|
||||||
|
(c.contact || '').toLowerCase().includes(s) ||
|
||||||
|
(c.email || '').toLowerCase().includes(s));
|
||||||
|
}
|
||||||
|
if (filterQbo === 'qbo') f = f.filter(c => c.qbo_id);
|
||||||
|
else if (filterQbo === 'local') f = f.filter(c => !c.qbo_id);
|
||||||
|
|
||||||
|
f.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
localStorage.setItem('cust_filterName', filterName);
|
||||||
|
localStorage.setItem('cust_filterQbo', filterQbo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Render
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function renderCustomerView() {
|
||||||
|
const tbody = document.getElementById('customers-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const filtered = getFilteredCustomers();
|
||||||
|
|
||||||
|
tbody.innerHTML = filtered.map(customer => {
|
||||||
|
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
|
||||||
|
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
||||||
|
let fullAddress = lines.join(', ');
|
||||||
|
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||||
|
|
||||||
|
// QBO Status
|
||||||
|
const qboStatus = customer.qbo_id
|
||||||
|
? `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`
|
||||||
|
: `<button onclick="window.customerView.exportToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer" title="Export customer to QBO">QBO Export</button>`;
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
const contactDisplay = customer.contact
|
||||||
|
? `<span class="text-xs text-gray-400 ml-1">(${customer.contact})</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Email
|
||||||
|
const emailDisplay = customer.email
|
||||||
|
? `<a href="mailto:${customer.email}" class="text-blue-600 hover:text-blue-800 text-sm">${customer.email}</a>`
|
||||||
|
: '<span class="text-gray-300 text-sm">—</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
${customer.name} ${qboStatus} ${contactDisplay}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">${fullAddress || '—'}</td>
|
||||||
|
<td class="px-4 py-3 text-sm">${emailDisplay}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '—'}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
|
<button onclick="window.customerView.edit(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
|
<button onclick="window.customerView.remove(${customer.id})" class="text-red-600 hover:text-red-900">Del</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No customers found.</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countEl = document.getElementById('customer-count');
|
||||||
|
if (countEl) countEl.textContent = filtered.length;
|
||||||
|
|
||||||
|
updateFilterButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterButtons() {
|
||||||
|
document.querySelectorAll('[data-qbo-filter]').forEach(btn => {
|
||||||
|
const s = btn.getAttribute('data-qbo-filter');
|
||||||
|
btn.classList.toggle('bg-blue-600', s === filterQbo);
|
||||||
|
btn.classList.toggle('text-white', s === filterQbo);
|
||||||
|
btn.classList.toggle('bg-white', s !== filterQbo);
|
||||||
|
btn.classList.toggle('text-gray-600', s !== filterQbo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Toolbar
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function injectToolbar() {
|
||||||
|
const c = document.getElementById('customer-toolbar');
|
||||||
|
if (!c) return;
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Search:</label>
|
||||||
|
<input type="text" id="customer-filter-name" placeholder="Name, contact, email..."
|
||||||
|
value="${filterName}"
|
||||||
|
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-56 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||||||
|
<button data-qbo-filter="all" onclick="window.customerView.setQboFilter('all')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">All</button>
|
||||||
|
<button data-qbo-filter="qbo" onclick="window.customerView.setQboFilter('qbo')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">In QBO</button>
|
||||||
|
<button data-qbo-filter="local" onclick="window.customerView.setQboFilter('local')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">Local Only</button>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-4">
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
<span id="customer-count" class="font-semibold text-gray-700">0</span> customers
|
||||||
|
</span>
|
||||||
|
<button onclick="window.customerView.openModal()"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">+ New Customer</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
updateFilterButtons();
|
||||||
|
document.getElementById('customer-filter-name').addEventListener('input', (e) => {
|
||||||
|
filterName = e.target.value; saveSettings(); renderCustomerView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Modal
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function ensureModalElement() {
|
||||||
|
let modal = document.getElementById('customer-modal-v2');
|
||||||
|
if (modal) return;
|
||||||
|
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'customer-modal-v2';
|
||||||
|
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openModal(customerId = null) {
|
||||||
|
ensureModalElement();
|
||||||
|
const modal = document.getElementById('customer-modal-v2');
|
||||||
|
const isEdit = !!customerId;
|
||||||
|
const customer = isEdit ? customers.find(c => c.id === customerId) : null;
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900">${isEdit ? 'Edit Customer' : 'New Customer'}</h3>
|
||||||
|
<button onclick="window.customerView.closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<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-v2" class="space-y-4">
|
||||||
|
<input type="hidden" id="cf-id" value="${customer?.id || ''}">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name *</label>
|
||||||
|
<input type="text" id="cf-name" required value="${customer?.name || ''}"
|
||||||
|
class="w-full px-3 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">Contact Person</label>
|
||||||
|
<input type="text" id="cf-contact" value="${customer?.contact || ''}" placeholder="First Last"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 pt-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Billing Address</label>
|
||||||
|
<input type="text" id="cf-line1" placeholder="Line 1 (Street / PO Box)" value="${customer?.line1 || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<input type="text" id="cf-line2" placeholder="Line 2" value="${customer?.line2 || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<input type="text" id="cf-line3" placeholder="Line 3" value="${customer?.line3 || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<input type="text" id="cf-line4" placeholder="Line 4" value="${customer?.line4 || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</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="cf-city" value="${customer?.city || ''}"
|
||||||
|
class="w-full px-3 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="cf-state" maxlength="2" placeholder="TX" value="${customer?.state || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
|
||||||
|
<input type="text" id="cf-zip" value="${customer?.zip_code || ''}"
|
||||||
|
class="w-full px-3 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 #</label>
|
||||||
|
<input type="text" id="cf-account" value="${customer?.account_number || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end pb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" id="cf-taxable" ${customer?.taxable !== false ? 'checked' : ''}
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<label for="cf-taxable" class="ml-2 text-sm text-gray-700">Taxable</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" id="cf-email" value="${customer?.email || ''}"
|
||||||
|
class="w-full px-3 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">Phone</label>
|
||||||
|
<input type="tel" id="cf-phone" value="${customer?.phone || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
|
||||||
|
<textarea id="cf-remarks" rows="3" placeholder="Internal notes about this customer..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">${customer?.remarks || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button type="button" onclick="window.customerView.closeModal()"
|
||||||
|
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 font-semibold">Save Customer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('customer-form-v2').addEventListener('submit', handleSubmit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeModal() {
|
||||||
|
const modal = document.getElementById('customer-modal-v2');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Submit
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('cf-name').value,
|
||||||
|
contact: document.getElementById('cf-contact').value || null,
|
||||||
|
line1: document.getElementById('cf-line1').value || null,
|
||||||
|
line2: document.getElementById('cf-line2').value || null,
|
||||||
|
line3: document.getElementById('cf-line3').value || null,
|
||||||
|
line4: document.getElementById('cf-line4').value || null,
|
||||||
|
city: document.getElementById('cf-city').value || null,
|
||||||
|
state: (document.getElementById('cf-state').value || '').toUpperCase() || null,
|
||||||
|
zip_code: document.getElementById('cf-zip').value || null,
|
||||||
|
account_number: document.getElementById('cf-account').value || null,
|
||||||
|
email: document.getElementById('cf-email').value || null,
|
||||||
|
phone: document.getElementById('cf-phone').value || null,
|
||||||
|
phone2: null,
|
||||||
|
taxable: document.getElementById('cf-taxable').checked,
|
||||||
|
remarks: document.getElementById('cf-remarks').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const customerId = document.getElementById('cf-id').value;
|
||||||
|
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
|
||||||
|
const method = customerId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
if (typeof showSpinner === 'function') showSpinner(customerId ? 'Saving customer & syncing QBO...' : 'Creating customer...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeModal();
|
||||||
|
await loadCustomers();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to save customer'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving customer:', error);
|
||||||
|
alert('Network error saving customer.');
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Actions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function edit(id) { openModal(id); }
|
||||||
|
|
||||||
|
export async function remove(id) {
|
||||||
|
const customer = customers.find(c => c.id === id);
|
||||||
|
if (!customer) return;
|
||||||
|
|
||||||
|
let msg = `Delete customer "${customer.name}"?`;
|
||||||
|
if (customer.qbo_id) msg += '\nThis will also deactivate the customer in QBO.';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) await loadCustomers();
|
||||||
|
else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Network error.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportToQbo(id) {
|
||||||
|
const customer = customers.find(c => c.id === id);
|
||||||
|
if (!customer) return;
|
||||||
|
if (!confirm(`Export "${customer.name}" to QuickBooks Online?`)) return;
|
||||||
|
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Exporting customer to QBO...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ "${result.name}" exported to QBO (ID: ${result.qbo_id}).`);
|
||||||
|
await loadCustomers();
|
||||||
|
} else {
|
||||||
|
alert(`❌ Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Network error.');
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setQboFilter(val) {
|
||||||
|
filterQbo = val;
|
||||||
|
saveSettings();
|
||||||
|
renderCustomerView();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.customerView = {
|
||||||
|
loadCustomers, renderCustomerView, getCustomers,
|
||||||
|
openModal, closeModal, edit, remove, exportToQbo, setQboFilter
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make customers available globally for other modules (quote/invoice dropdowns)
|
||||||
|
window.getCustomers = () => customers;
|
||||||
589
public/js/views/invoice-view.js
Normal file
589
public/js/views/invoice-view.js
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
// invoice-view.js — ES Module v5
|
||||||
|
// Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button
|
||||||
|
|
||||||
|
let invoices = [];
|
||||||
|
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
|
||||||
|
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
|
||||||
|
let groupBy = localStorage.getItem('inv_groupBy') || 'none';
|
||||||
|
|
||||||
|
const OVERDUE_DAYS = 30;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Date Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function parseLocalDate(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const str = String(dateStr).split('T')[0];
|
||||||
|
const parts = str.split('-');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return '—';
|
||||||
|
const d = parseLocalDate(date);
|
||||||
|
if (!d) return '—';
|
||||||
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
if (!isoStr) return 'Never';
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) +
|
||||||
|
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysSince(date) {
|
||||||
|
const d = parseLocalDate(date);
|
||||||
|
if (!d) return 0;
|
||||||
|
const now = new Date(); now.setHours(0, 0, 0, 0);
|
||||||
|
return Math.floor((now - d) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekNumber(date) {
|
||||||
|
const d = parseLocalDate(date);
|
||||||
|
if (!d) return { year: 0, week: 0 };
|
||||||
|
const copy = new Date(d.getTime());
|
||||||
|
copy.setHours(0, 0, 0, 0);
|
||||||
|
copy.setDate(copy.getDate() + 3 - ((copy.getDay() + 6) % 7));
|
||||||
|
const week1 = new Date(copy.getFullYear(), 0, 4);
|
||||||
|
return {
|
||||||
|
year: copy.getFullYear(),
|
||||||
|
week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekRange(year, weekNum) {
|
||||||
|
const jan4 = new Date(year, 0, 4);
|
||||||
|
const dayOfWeek = jan4.getDay() || 7;
|
||||||
|
const monday = new Date(jan4);
|
||||||
|
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
|
||||||
|
const sunday = new Date(monday);
|
||||||
|
sunday.setDate(monday.getDate() + 6);
|
||||||
|
const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
|
return { start: fmt(monday), end: fmt(sunday) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthName(i) {
|
||||||
|
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaid(inv) { return !!inv.paid_date; }
|
||||||
|
function isDraft(inv) { return !inv.qbo_id; }
|
||||||
|
function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
|
||||||
|
function isPartiallyPaid(inv) {
|
||||||
|
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||||
|
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
|
||||||
|
return !inv.paid_date && amountPaid > 0 && balance > 0;
|
||||||
|
}
|
||||||
|
function isSent(inv) {
|
||||||
|
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status === 'sent';
|
||||||
|
}
|
||||||
|
function isOpen(inv) {
|
||||||
|
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status !== 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
localStorage.setItem('inv_filterStatus', filterStatus);
|
||||||
|
localStorage.setItem('inv_groupBy', groupBy);
|
||||||
|
localStorage.setItem('inv_filterCustomer', filterCustomer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Data
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function loadInvoices() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/invoices');
|
||||||
|
invoices = await response.json();
|
||||||
|
renderInvoiceView();
|
||||||
|
loadLastSync();
|
||||||
|
} catch (error) { console.error('Error loading invoices:', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLastSync() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/qbo/last-sync');
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('last-sync-time');
|
||||||
|
if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced';
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInvoicesData() { return invoices; }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Filter / Sort / Group
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function getFilteredInvoices() {
|
||||||
|
let f = [...invoices];
|
||||||
|
if (filterStatus === 'unpaid') f = f.filter(i => !isPaid(i));
|
||||||
|
else if (filterStatus === 'paid') f = f.filter(i => isPaid(i));
|
||||||
|
else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i));
|
||||||
|
else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(i));
|
||||||
|
else if (filterStatus === 'sent') f = f.filter(i => isSent(i));
|
||||||
|
else if (filterStatus === 'open') f = f.filter(i => isOpen(i));
|
||||||
|
|
||||||
|
if (filterCustomer.trim()) {
|
||||||
|
const s = filterCustomer.toLowerCase();
|
||||||
|
f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s));
|
||||||
|
}
|
||||||
|
f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective amount: for unpaid/partial show balance, for paid show total
|
||||||
|
function effectiveAmount(inv) {
|
||||||
|
const total = parseFloat(inv.total) || 0;
|
||||||
|
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||||
|
if (inv.paid_date) return total; // Paid → show full total
|
||||||
|
if (amountPaid > 0) return total - amountPaid; // Partial → show balance
|
||||||
|
return total; // Unpaid → show total
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupInvoices(filtered) {
|
||||||
|
if (groupBy === 'none') return null;
|
||||||
|
const groups = new Map();
|
||||||
|
filtered.forEach(inv => {
|
||||||
|
const d = parseLocalDate(inv.invoice_date);
|
||||||
|
if (!d) return;
|
||||||
|
let key, label;
|
||||||
|
if (groupBy === 'week') {
|
||||||
|
const wk = getWeekNumber(inv.invoice_date);
|
||||||
|
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
|
||||||
|
const range = getWeekRange(wk.year, wk.week);
|
||||||
|
label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`;
|
||||||
|
} else {
|
||||||
|
key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
|
||||||
|
label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 });
|
||||||
|
const g = groups.get(key);
|
||||||
|
g.invoices.push(inv);
|
||||||
|
g.total += effectiveAmount(inv);
|
||||||
|
});
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
g.invoices.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
|
||||||
|
}
|
||||||
|
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Render
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderInvoiceRow(invoice) {
|
||||||
|
const hasQbo = !!invoice.qbo_id;
|
||||||
|
const paid = isPaid(invoice);
|
||||||
|
const overdue = isOverdue(invoice);
|
||||||
|
const draft = isDraft(invoice);
|
||||||
|
const amountPaid = parseFloat(invoice.amount_paid) || 0;
|
||||||
|
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
||||||
|
const partial = isPartiallyPaid(invoice);
|
||||||
|
|
||||||
|
const stripeIndicator = invoice.stripe_payment_link_id
|
||||||
|
? (invoice.stripe_payment_status === 'paid'
|
||||||
|
? ' <span title="Stripe payment received" class="text-purple-500 text-xs">💳✓</span>'
|
||||||
|
: ' <span title="Stripe payment link active" class="text-purple-400 text-xs">💳</span>')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const invNumDisplay = invoice.invoice_number
|
||||||
|
? invoice.invoice_number + stripeIndicator
|
||||||
|
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||||||
|
|
||||||
|
// Status Badge (left side, next to invoice number)
|
||||||
|
let statusBadge = '';
|
||||||
|
if (paid && invoice.payment_status === 'Deposited') {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
||||||
|
} else if (paid && invoice.payment_status === 'Stripe') {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800" title="Stripe payment ${formatDate(invoice.paid_date)}">Stripe</span>`;
|
||||||
|
} else if (paid) {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||||||
|
} else if (partial) {
|
||||||
|
// Partial: show delivery status badge + Partial badge
|
||||||
|
if (hasQbo && invoice.email_status === 'sent') {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span> `;
|
||||||
|
} else if (hasQbo) {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span> `;
|
||||||
|
}
|
||||||
|
statusBadge += `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial</span>`;
|
||||||
|
} else if (overdue) {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
|
||||||
|
} else if (hasQbo && invoice.email_status === 'sent') {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
|
||||||
|
} else if (hasQbo) {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Date
|
||||||
|
let sendDateDisplay = '—';
|
||||||
|
if (invoice.scheduled_send_date) {
|
||||||
|
const sendDate = parseLocalDate(invoice.scheduled_send_date);
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
const daysUntil = Math.floor((sendDate - today) / 86400000);
|
||||||
|
sendDateDisplay = formatDate(invoice.scheduled_send_date);
|
||||||
|
if (!paid && invoice.email_status !== 'sent') {
|
||||||
|
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
||||||
|
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
|
||||||
|
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount column — show balance when partially paid
|
||||||
|
let amountDisplay;
|
||||||
|
if (partial) {
|
||||||
|
amountDisplay = `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`;
|
||||||
|
} else {
|
||||||
|
amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del ---
|
||||||
|
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||||||
|
|
||||||
|
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||||
|
let qboBtn;
|
||||||
|
if (hasQbo) {
|
||||||
|
qboBtn = `<span class="text-green-600 text-xs" title="QBO ID: ${invoice.qbo_id}">✓ QBO</span>`;
|
||||||
|
} else if (!customerHasQbo) {
|
||||||
|
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
||||||
|
} else {
|
||||||
|
qboBtn = `<span class="text-gray-400 text-xs" title="Will be exported to QBO on save">QBO pending</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBtn = draft
|
||||||
|
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF available after QBO Export">PDF</span>`
|
||||||
|
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
|
||||||
|
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
|
||||||
|
|
||||||
|
// Payment button — only for QBO invoices that are not fully paid
|
||||||
|
let paidBtn = '';
|
||||||
|
if (!paid && hasQbo) {
|
||||||
|
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark Sent button (right side) — only when open, not paid/partial
|
||||||
|
let sendBtn = '';
|
||||||
|
if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
|
||||||
|
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
||||||
|
}
|
||||||
|
// if (hasQbo && !paid && !overdue) {
|
||||||
|
// sendBtn = `
|
||||||
|
// <button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-gray-600 hover:text-gray-900 text-xs font-medium mr-4" title="Nur Status ändern">
|
||||||
|
// ✔️ Mark Sent
|
||||||
|
// </button>
|
||||||
|
// <button onclick="window.emailModal.open(${invoice.id})" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="E-Mail via SES versenden">
|
||||||
|
// 📧 Send Email
|
||||||
|
// </button>
|
||||||
|
// `; }
|
||||||
|
|
||||||
|
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||||
|
|
||||||
|
const stripeEmailBtn = (!paid && hasQbo)
|
||||||
|
? `<button onclick="window.emailModal.open(${invoice.id})" title="Email with Stripe Payment Link" class="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 text-xs font-semibold">💳 Pay Link</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid)
|
||||||
|
? `<button onclick="window.invoiceView.checkStripePayment(${invoice.id})" title="Check Stripe Payment Status" class="px-2 py-1 bg-purple-50 text-purple-600 rounded hover:bg-purple-100 text-xs font-semibold">🔍 Check</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||||||
|
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupHeader(label) {
|
||||||
|
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
|
||||||
|
}
|
||||||
|
function renderGroupFooter(total, count) {
|
||||||
|
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
|
||||||
|
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderInvoiceView() {
|
||||||
|
const tbody = document.getElementById('invoices-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
const filtered = getFilteredInvoices();
|
||||||
|
const groups = groupInvoices(filtered);
|
||||||
|
let html = '', grandTotal = 0;
|
||||||
|
|
||||||
|
if (groups) {
|
||||||
|
for (const [, group] of groups) {
|
||||||
|
html += renderGroupHeader(group.label);
|
||||||
|
group.invoices.forEach(inv => { html += renderInvoiceRow(inv); });
|
||||||
|
html += renderGroupFooter(group.total, group.invoices.length);
|
||||||
|
grandTotal += group.total;
|
||||||
|
}
|
||||||
|
if (groups.size > 1) {
|
||||||
|
html += `<tr class="bg-blue-100 border-t-4 border-blue-400">
|
||||||
|
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
|
||||||
|
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += effectiveAmount(inv); });
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
|
||||||
|
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
|
||||||
|
const countEl = document.getElementById('invoice-count');
|
||||||
|
if (countEl) countEl.textContent = filtered.length;
|
||||||
|
updateStatusButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusButtons() {
|
||||||
|
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
||||||
|
const s = btn.getAttribute('data-status-filter');
|
||||||
|
btn.classList.toggle('bg-blue-600', s === filterStatus);
|
||||||
|
btn.classList.toggle('text-white', s === filterStatus);
|
||||||
|
btn.classList.toggle('bg-white', s !== filterStatus);
|
||||||
|
btn.classList.toggle('text-gray-600', s !== filterStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
unpaid: invoices.filter(i => !isPaid(i)).length,
|
||||||
|
open: invoices.filter(i => isOpen(i)).length,
|
||||||
|
sent: invoices.filter(i => isSent(i)).length,
|
||||||
|
partial: invoices.filter(i => isPartiallyPaid(i)).length,
|
||||||
|
paid: invoices.filter(i => isPaid(i)).length,
|
||||||
|
overdue: invoices.filter(i => isOverdue(i)).length
|
||||||
|
};
|
||||||
|
|
||||||
|
['unpaid', 'open', 'sent', 'partial', 'paid', 'overdue'].forEach(key => {
|
||||||
|
const el = document.getElementById(`${key}-badge`);
|
||||||
|
if (el) {
|
||||||
|
el.textContent = counts[key];
|
||||||
|
el.classList.toggle('hidden', counts[key] === 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Toolbar
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function injectToolbar() {
|
||||||
|
const c = document.getElementById('invoice-toolbar');
|
||||||
|
if (!c) return;
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||||||
|
<button data-status-filter="all" onclick="window.invoiceView.setStatus('all')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">All</button>
|
||||||
|
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Unpaid
|
||||||
|
<span id="unpaid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||||
|
<button data-status-filter="open" onclick="window.invoiceView.setStatus('open')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Open
|
||||||
|
<span id="open-badge" class="hidden absolute -top-1.5 -right-1.5 bg-orange-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||||
|
<button data-status-filter="sent" onclick="window.invoiceView.setStatus('sent')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Sent
|
||||||
|
<span id="sent-badge" class="hidden absolute -top-1.5 -right-1.5 bg-cyan-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||||
|
<button data-status-filter="partial" onclick="window.invoiceView.setStatus('partial')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Partial
|
||||||
|
<span id="partial-badge" class="hidden absolute -top-1.5 -right-1.5 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||||
|
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid
|
||||||
|
<span id="paid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||||
|
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
|
||||||
|
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
||||||
|
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
|
||||||
|
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Group:</label>
|
||||||
|
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
|
||||||
|
<option value="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
|
||||||
|
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
|
||||||
|
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="window.invoiceView.syncFromQBO()" class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-xs font-medium hover:bg-indigo-700">
|
||||||
|
⟳ Sync from QBO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-4">
|
||||||
|
<span id="last-sync-time" class="text-xs text-gray-400">...</span>
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
updateStatusButtons();
|
||||||
|
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
||||||
|
filterCustomer = e.target.value; saveSettings(); renderInvoiceView();
|
||||||
|
});
|
||||||
|
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
|
||||||
|
groupBy = e.target.value; saveSettings(); renderInvoiceView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Actions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
|
||||||
|
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
|
||||||
|
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
|
||||||
|
|
||||||
|
export async function exportToQBO(id) {
|
||||||
|
if (!confirm('Export invoice to QuickBooks Online?')) return;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Exporting invoice to QBO...');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
||||||
|
else alert(`❌ ${d.error}`);
|
||||||
|
} catch (e) { alert('Network error.'); }
|
||||||
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncToQBO(id) {
|
||||||
|
if (!confirm('Sync changes to QuickBooks Online?')) return;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) { alert(`✅ ${d.message}`); loadInvoices(); }
|
||||||
|
else alert(`❌ ${d.error}`);
|
||||||
|
} catch (e) { alert('Network error.'); }
|
||||||
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncFromQBO() {
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/qbo/sync-payments', { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
alert(`✅ ${d.message}`);
|
||||||
|
loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert(`❌ ${d.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) { alert('Network error.'); }
|
||||||
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setEmailStatus(id, status) {
|
||||||
|
const label = status === 'sent' ? 'Mark as sent' : 'Mark as not sent';
|
||||||
|
if (!confirm(`${label}?`)) return;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner(`Updating status in QBO...`);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/invoices/${id}/email-status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) loadInvoices();
|
||||||
|
else alert(`❌ ${d.error}`);
|
||||||
|
} catch (e) { alert('Network error.'); }
|
||||||
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetQbo(id) {
|
||||||
|
if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
|
||||||
|
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPaid(id) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/invoices/${id}/mark-paid`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
|
||||||
|
});
|
||||||
|
if (r.ok) loadInvoices();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
|
||||||
|
|
||||||
|
export async function remove(id) {
|
||||||
|
if (!confirm('Delete this invoice?')) return;
|
||||||
|
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
async function checkStripePayment(invoiceId) {
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Checking Stripe payment status...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${invoiceId}/check-payment`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (result.paid) {
|
||||||
|
let msg = `✅ ${result.message}`;
|
||||||
|
if (result.qbo) {
|
||||||
|
if (result.qbo.error) {
|
||||||
|
msg += `\n\n⚠️ QBO booking failed: ${result.qbo.error}`;
|
||||||
|
} else {
|
||||||
|
msg += `\n\n📗 QBO Payment recorded (ID: ${result.qbo.paymentId})`;
|
||||||
|
if (result.qbo.feeBooked) msg += '\n📗 Processing fee booked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result.fullyPaid) {
|
||||||
|
msg += '\n\n⚠️ Partial payment — invoice is not fully paid yet.';
|
||||||
|
}
|
||||||
|
alert(msg);
|
||||||
|
loadInvoices(); // Refresh the list
|
||||||
|
} else if (result.alreadyProcessed) {
|
||||||
|
alert('ℹ️ Stripe payment was already recorded for this invoice.');
|
||||||
|
} else if (result.status === 'processing') {
|
||||||
|
alert('⏳ ACH payment is processing (typically 3-5 business days).\n\nCheck again later.');
|
||||||
|
} else {
|
||||||
|
alert('ℹ️ No payment received yet.\n\nThe customer may not have clicked the payment link, or the payment is still being processed.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`❌ Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Check Stripe payment error:', e);
|
||||||
|
alert('Network error checking payment status.');
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ============================================================
|
||||||
|
// Expose
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.invoiceView = {
|
||||||
|
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
|
||||||
|
loadInvoices, renderInvoiceView, setStatus, checkStripePayment
|
||||||
|
};
|
||||||
101
public/js/views/quote-view.js
Normal file
101
public/js/views/quote-view.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* quote-view.js — Quote list rendering and actions
|
||||||
|
* Analog to invoice-view.js
|
||||||
|
*/
|
||||||
|
import { formatDate } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let quotes = [];
|
||||||
|
|
||||||
|
export async function loadQuotes() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/quotes');
|
||||||
|
quotes = await response.json();
|
||||||
|
renderQuotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading quotes:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotesData() {
|
||||||
|
return quotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQuotes() {
|
||||||
|
const tbody = document.getElementById('quotes-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
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="window.quoteView.viewPDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||||
|
<button onclick="window.quoteView.convertToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
|
||||||
|
<button onclick="window.quoteView.edit(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
|
<button onclick="window.quoteView.remove(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (quotes.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No quotes found.</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewPDF(id) {
|
||||||
|
window.open(`/api/quotes/${id}/pdf`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function edit(id) {
|
||||||
|
if (typeof window.openQuoteModal === 'function') {
|
||||||
|
await window.openQuoteModal(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertToInvoice(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 || '(Draft)'} created successfully!`);
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
|
if (typeof window.showTab === 'function') window.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.quoteView = {
|
||||||
|
loadQuotes,
|
||||||
|
renderQuotes,
|
||||||
|
viewPDF,
|
||||||
|
edit,
|
||||||
|
remove,
|
||||||
|
convertToInvoice
|
||||||
|
};
|
||||||
182
public/js/views/settings-view.js
Normal file
182
public/js/views/settings-view.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* settings-view.js — Logo upload, QBO import, QBO connection test
|
||||||
|
* Extracted from app.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentLogoFile = null;
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSettingsView() {
|
||||||
|
const logoUpload = document.getElementById('logo-upload');
|
||||||
|
if (logoUpload) {
|
||||||
|
logoUpload.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkQboOverdue() {
|
||||||
|
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
|
||||||
|
const resultDiv = document.getElementById('qbo-result');
|
||||||
|
const tbody = document.getElementById('qbo-result-list');
|
||||||
|
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳ Connecting to QBO...';
|
||||||
|
btn.disabled = true;
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/overdue');
|
||||||
|
const invoices = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = invoices.map(inv => `
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
|
||||||
|
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
|
||||||
|
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
|
||||||
|
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
|
||||||
|
} else {
|
||||||
|
throw new Error(invoices.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QBO Test Error:', error);
|
||||||
|
alert('❌ Connection Test Failed: ' + error.message);
|
||||||
|
tbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importFromQBO() {
|
||||||
|
if (!confirm(
|
||||||
|
'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
|
||||||
|
'• Bereits importierte werden übersprungen\n' +
|
||||||
|
'• Nur Kunden die lokal verknüpft sind\n\n' +
|
||||||
|
'Fortfahren?'
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
const btn = document.querySelector('button[onclick="importFromQBO()"]');
|
||||||
|
const resultDiv = document.getElementById('qbo-import-result');
|
||||||
|
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳ Importiere aus QBO...';
|
||||||
|
btn.disabled = true;
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
let html = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
|
||||||
|
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
|
||||||
|
html += `<ul class="text-sm text-gray-700 space-y-1">`;
|
||||||
|
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
|
||||||
|
}
|
||||||
|
if (result.skippedNoCustomer > 0) {
|
||||||
|
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
|
||||||
|
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</ul></div>`;
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
|
||||||
|
if (result.imported > 0 && window.invoiceView) {
|
||||||
|
window.invoiceView.loadInvoices();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
|
||||||
|
<p class="text-sm text-red-600 mt-1">${result.error}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import Error:', error);
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="text-red-600">Netzwerkfehler beim Import.</p>
|
||||||
|
</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.uploadLogo = uploadLogo;
|
||||||
|
window.checkQboOverdue = checkQboOverdue;
|
||||||
|
window.importFromQBO = importFromQBO;
|
||||||
41
public/logo.svg
Normal file
41
public/logo.svg
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 10.583333 10.583333"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
id="layer1">
|
||||||
|
<g
|
||||||
|
id="g2"
|
||||||
|
transform="translate(-59.849599,-62.962344)">
|
||||||
|
<g
|
||||||
|
id="g3"
|
||||||
|
transform="matrix(2.4622373,0,0,2.4622373,-103.30064,-98.368198)">
|
||||||
|
<path
|
||||||
|
id="rect4"
|
||||||
|
style="opacity:0.92;fill:#000080;fill-opacity:0;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||||
|
d="m 69.557843,68.36669 v -1.921262 h -1.960031 v 1.445588 c 0,0.26351 0.212068,0.475674 0.475578,0.475674 z" />
|
||||||
|
<path
|
||||||
|
id="path1"
|
||||||
|
style="opacity:0.92;fill:#000080;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||||
|
d="m 68.07339,65.851028 c -0.26351,0 -0.475578,0.212067 -0.475578,0.475579 v 0.118821 h 1.960031 v 1.921262 h 0.10113 c 0.26351,0 0.475578,-0.212164 0.475578,-0.475674 v -1.564409 c 0,-0.263512 -0.212068,-0.475579 -0.475578,-0.475579 z" />
|
||||||
|
<path
|
||||||
|
id="rect14"
|
||||||
|
style="opacity:0.92;fill:#0000ff;fill-opacity:0;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||||
|
d="m 68.587351,69.424584 v -1.960128 h -1.99822 v 1.484453 c 0,0.26351 0.212067,0.475675 0.475577,0.475675 z" />
|
||||||
|
<path
|
||||||
|
id="path2"
|
||||||
|
style="opacity:0.92;fill:#0000ff;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||||
|
d="m 67.064708,66.908921 c -0.26351,0 -0.475577,0.212068 -0.475577,0.475578 v 0.07996 h 1.99822 v 1.960128 h 0.06294 c 0.263512,0 0.475579,-0.212165 0.475579,-0.475675 v -1.56441 c 0,-0.26351 -0.212067,-0.475578 -0.475579,-0.475578 z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
49
qbo_query.js
Normal file
49
qbo_query.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// qbo_query.js — Quick QBO Query Tool
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node qbo_query.js invoice 110444 # By DocNumber
|
||||||
|
// node qbo_query.js invoice-id 37973 # By QBO ID
|
||||||
|
// node qbo_query.js payment 38733 # By Payment ID
|
||||||
|
// node qbo_query.js query "SELECT * FROM Invoice WHERE Balance = '0' MAXRESULTS 3"
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [type, value] = process.argv.slice(2);
|
||||||
|
if (!type || !value) {
|
||||||
|
console.log('Usage:\n node qbo_query.js invoice <DocNumber>\n node qbo_query.js invoice-id <QBO_ID>\n node qbo_query.js payment <Payment_ID>\n node qbo_query.js query "<QUERY>"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const base = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if (type === 'invoice') {
|
||||||
|
url = `${base}/v3/company/${companyId}/query?query=${encodeURI(`SELECT * FROM Invoice WHERE DocNumber = '${value}'`)}`;
|
||||||
|
} else if (type === 'invoice-id') {
|
||||||
|
url = `${base}/v3/company/${companyId}/invoice/${value}`;
|
||||||
|
} else if (type === 'payment') {
|
||||||
|
url = `${base}/v3/company/${companyId}/payment/${value}`;
|
||||||
|
} else if (type === 'query') {
|
||||||
|
url = `${base}/v3/company/${companyId}/query?query=${encodeURI(value)}`;
|
||||||
|
} else {
|
||||||
|
console.error('Unknown type:', type);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeQboApiCall({ url, method: 'GET' });
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
453
schema.sql
Normal file
453
schema.sql
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
\restrict XHJaQEVNwjEtL1FZTBb0Sf7ooBX1Ld95BOqQlHUgJxKe87sxBoQbgpWG7aympDU
|
||||||
|
|
||||||
|
-- Dumped from database version 17.6
|
||||||
|
-- Dumped by pg_dump version 17.6
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET transaction_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
SET default_tablespace = '';
|
||||||
|
|
||||||
|
SET default_table_access_method = heap;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: customers; Type: TABLE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.customers (
|
||||||
|
id integer NOT NULL,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
city character varying(100),
|
||||||
|
state character varying(2),
|
||||||
|
zip_code character varying(10),
|
||||||
|
account_number character varying(50),
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
email character varying(255),
|
||||||
|
phone character varying(50),
|
||||||
|
phone2 character varying(50),
|
||||||
|
taxable boolean DEFAULT true,
|
||||||
|
line1 character varying(255),
|
||||||
|
line2 character varying(255),
|
||||||
|
line3 character varying(255),
|
||||||
|
line4 character varying(255),
|
||||||
|
qbo_id character varying(50),
|
||||||
|
qbo_sync_token character varying(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.customers OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: customers_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.customers_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.customers_id_seq OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: customers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.customers_id_seq OWNED BY public.customers.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoice_items; Type: TABLE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.invoice_items (
|
||||||
|
id integer NOT NULL,
|
||||||
|
invoice_id integer,
|
||||||
|
quantity character varying(20) NOT NULL,
|
||||||
|
description text NOT NULL,
|
||||||
|
rate character varying(50) NOT NULL,
|
||||||
|
amount character varying(50) NOT NULL,
|
||||||
|
item_order integer NOT NULL,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
qbo_item_id character varying(10) DEFAULT '9'::character varying
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.invoice_items OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoice_items_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.invoice_items_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.invoice_items_id_seq OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoice_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices; Type: TABLE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.invoices (
|
||||||
|
id integer NOT NULL,
|
||||||
|
invoice_number character varying(50) NOT NULL,
|
||||||
|
customer_id integer,
|
||||||
|
invoice_date date NOT NULL,
|
||||||
|
terms character varying(100) DEFAULT 'Net 30'::character varying,
|
||||||
|
auth_code character varying(255),
|
||||||
|
tax_exempt boolean DEFAULT false,
|
||||||
|
tax_rate numeric(5,2) DEFAULT 8.25,
|
||||||
|
subtotal numeric(10,2) DEFAULT 0,
|
||||||
|
tax_amount numeric(10,2) DEFAULT 0,
|
||||||
|
total numeric(10,2) DEFAULT 0,
|
||||||
|
created_from_quote_id integer,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
qbo_id character varying(50),
|
||||||
|
qbo_sync_token character varying(50),
|
||||||
|
qbo_doc_number character varying(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.invoices OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.invoices_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.invoices_id_seq OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.invoices_id_seq OWNED BY public.invoices.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quote_items; Type: TABLE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.quote_items (
|
||||||
|
id integer NOT NULL,
|
||||||
|
quote_id integer,
|
||||||
|
quantity character varying(20) NOT NULL,
|
||||||
|
description text NOT NULL,
|
||||||
|
rate character varying(50) NOT NULL,
|
||||||
|
amount character varying(50) NOT NULL,
|
||||||
|
item_order integer NOT NULL,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_tbd boolean DEFAULT false,
|
||||||
|
qbo_item_id character varying(10) DEFAULT '9'::character varying
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.quote_items OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quote_items_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.quote_items_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.quote_items_id_seq OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quote_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.quote_items_id_seq OWNED BY public.quote_items.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes; Type: TABLE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.quotes (
|
||||||
|
id integer NOT NULL,
|
||||||
|
quote_number character varying(50) NOT NULL,
|
||||||
|
customer_id integer,
|
||||||
|
quote_date date NOT NULL,
|
||||||
|
tax_exempt boolean DEFAULT false,
|
||||||
|
tax_rate numeric(5,2) DEFAULT 8.25,
|
||||||
|
subtotal numeric(10,2) DEFAULT 0,
|
||||||
|
tax_amount numeric(10,2) DEFAULT 0,
|
||||||
|
total numeric(10,2) DEFAULT 0,
|
||||||
|
has_tbd boolean DEFAULT false,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
tbd_note text
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.quotes OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.quotes_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.quotes_id_seq OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.quotes_id_seq OWNED BY public.quotes.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.customers ALTER COLUMN id SET DEFAULT nextval('public.customers_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoice_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoice_items ALTER COLUMN id SET DEFAULT nextval('public.invoice_items_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoices ALTER COLUMN id SET DEFAULT nextval('public.invoices_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quote_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quote_items ALTER COLUMN id SET DEFAULT nextval('public.quote_items_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quotes ALTER COLUMN id SET DEFAULT nextval('public.quotes_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.customers
|
||||||
|
ADD CONSTRAINT customers_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: customers customers_qbo_id_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.customers
|
||||||
|
ADD CONSTRAINT customers_qbo_id_key UNIQUE (qbo_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoice_items invoice_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoice_items
|
||||||
|
ADD CONSTRAINT invoice_items_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices invoices_invoice_number_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoices
|
||||||
|
ADD CONSTRAINT invoices_invoice_number_key UNIQUE (invoice_number);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoices
|
||||||
|
ADD CONSTRAINT invoices_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quote_items quote_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quote_items
|
||||||
|
ADD CONSTRAINT quote_items_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes quotes_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quotes
|
||||||
|
ADD CONSTRAINT quotes_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes quotes_quote_number_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quotes
|
||||||
|
ADD CONSTRAINT quotes_quote_number_key UNIQUE (quote_number);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_customers_qbo_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_customers_qbo_id ON public.customers USING btree (qbo_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_invoice_items_invoice_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoice_items_invoice_id ON public.invoice_items USING btree (invoice_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_invoices_created_from_quote; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoices_created_from_quote ON public.invoices USING btree (created_from_quote_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_invoices_customer_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoices_customer_id ON public.invoices USING btree (customer_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_invoices_invoice_number; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoices_invoice_number ON public.invoices USING btree (invoice_number);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_quote_items_quote_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_quote_items_quote_id ON public.quote_items USING btree (quote_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_quotes_customer_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_quotes_customer_id ON public.quotes USING btree (customer_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_quotes_quote_number; Type: INDEX; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_quotes_quote_number ON public.quotes USING btree (quote_number);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoice_items invoice_items_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoice_items
|
||||||
|
ADD CONSTRAINT invoice_items_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices invoices_created_from_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoices
|
||||||
|
ADD CONSTRAINT invoices_created_from_quote_id_fkey FOREIGN KEY (created_from_quote_id) REFERENCES public.quotes(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: invoices invoices_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.invoices
|
||||||
|
ADD CONSTRAINT invoices_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quote_items quote_items_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quote_items
|
||||||
|
ADD CONSTRAINT quote_items_quote_id_fkey FOREIGN KEY (quote_id) REFERENCES public.quotes(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: quotes quotes_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.quotes
|
||||||
|
ADD CONSTRAINT quotes_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
|
||||||
|
\unrestrict XHJaQEVNwjEtL1FZTBb0Sf7ooBX1Ld95BOqQlHUgJxKe87sxBoQbgpWG7aympDU
|
||||||
|
|
||||||
11
src/config/database.js
Normal file
11
src/config/database.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
database: process.env.DB_NAME || 'quotes_db',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { pool };
|
||||||
27
src/config/qbo.js
Normal file
27
src/config/qbo.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// src/config/qbo.js
|
||||||
|
const OAuthClient = require('intuit-oauth');
|
||||||
|
const {
|
||||||
|
getOAuthClient: getClient,
|
||||||
|
saveTokens,
|
||||||
|
resetOAuthClient,
|
||||||
|
makeQboApiCall // <-- NEU: Direkt hier mit importieren
|
||||||
|
} = require('../../qbo_helper');
|
||||||
|
|
||||||
|
function getOAuthClient() {
|
||||||
|
return getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQboBaseUrl() {
|
||||||
|
return process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
OAuthClient,
|
||||||
|
getOAuthClient,
|
||||||
|
getQboBaseUrl,
|
||||||
|
saveTokens,
|
||||||
|
resetOAuthClient,
|
||||||
|
makeQboApiCall // <-- NEU: Und sauber weiterreichen
|
||||||
|
};
|
||||||
135
src/index.js
Normal file
135
src/index.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Quote & Invoice System - Main Entry Point
|
||||||
|
* Modularized Backend
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
|
||||||
|
// Import config
|
||||||
|
const { pool } = require('./config/database');
|
||||||
|
const { OAuthClient, getOAuthClient, saveTokens } = require('./config/qbo');
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
const customerRoutes = require('./routes/customers');
|
||||||
|
const quoteRoutes = require('./routes/quotes');
|
||||||
|
const invoiceRoutes = require('./routes/invoices');
|
||||||
|
const paymentRoutes = require('./routes/payments');
|
||||||
|
const qboRoutes = require('./routes/qbo');
|
||||||
|
const settingsRoutes = require('./routes/settings');
|
||||||
|
|
||||||
|
// Import PDF service for browser initialization
|
||||||
|
const { setBrowser } = require('./services/pdf-service');
|
||||||
|
|
||||||
|
// Import recurring invoice scheduler
|
||||||
|
const { startRecurringScheduler } = require('./services/recurring-service');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Global browser instance
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
// Initialize browser on startup
|
||||||
|
async function initBrowser() {
|
||||||
|
if (!browser) {
|
||||||
|
console.log('[BROWSER] Launching persistent browser...');
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-software-rasterizer',
|
||||||
|
'--no-zygote',
|
||||||
|
'--single-process'
|
||||||
|
],
|
||||||
|
protocolTimeout: 180000,
|
||||||
|
timeout: 180000
|
||||||
|
});
|
||||||
|
console.log('[BROWSER] Browser launched and ready');
|
||||||
|
|
||||||
|
// Pass browser to PDF service
|
||||||
|
setBrowser(browser);
|
||||||
|
|
||||||
|
// Restart browser if it crashes
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
console.log('[BROWSER] Browser disconnected, restarting...');
|
||||||
|
browser = null;
|
||||||
|
setBrowser(null);
|
||||||
|
initBrowser();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// QBO OAuth Routes — mounted at root level (not under /api/qbo)
|
||||||
|
// These must match the Intuit callback URL configuration
|
||||||
|
// =====================================================
|
||||||
|
app.get('/auth/qbo', (req, res) => {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
const authUri = client.authorizeUri({
|
||||||
|
scope: [OAuthClient.scopes.Accounting],
|
||||||
|
state: 'intuit-qbo-auth'
|
||||||
|
});
|
||||||
|
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
||||||
|
res.redirect(authUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/qbo/callback', async (req, res) => {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
try {
|
||||||
|
const authResponse = await client.createToken(req.url);
|
||||||
|
console.log('✅ QBO Authorization erfolgreich!');
|
||||||
|
saveTokens();
|
||||||
|
res.redirect('/#settings');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ QBO Authorization fehlgeschlagen:', e);
|
||||||
|
res.status(500).send(`
|
||||||
|
<h2>QBO Authorization Failed</h2>
|
||||||
|
<p>${e.message || e}</p>
|
||||||
|
<a href="/">Zurück zur App</a>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API Routes
|
||||||
|
// =====================================================
|
||||||
|
app.use('/api/customers', customerRoutes);
|
||||||
|
app.use('/api/quotes', quoteRoutes);
|
||||||
|
app.use('/api/invoices', invoiceRoutes);
|
||||||
|
app.use('/api/payments', paymentRoutes);
|
||||||
|
app.use('/api/qbo', qboRoutes);
|
||||||
|
app.use('/api', settingsRoutes);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
async function startServer() {
|
||||||
|
await initBrowser();
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Quote System running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start recurring invoice scheduler (checks every 24h)
|
||||||
|
startRecurringScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
270
src/routes/customers.js
Normal file
270
src/routes/customers.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Customer Routes
|
||||||
|
* Handles customer CRUD operations and QBO sync
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
|
|
||||||
|
// GET all customers
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM customers ORDER BY name');
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customers:', error);
|
||||||
|
res.status(500).json({ error: 'Error fetching customers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST create customer
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const {
|
||||||
|
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
||||||
|
account_number, email, phone, phone2, taxable, remarks
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`,
|
||||||
|
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||||
|
city || null, state || null, zip_code || null, account_number || null,
|
||||||
|
email || null, phone || null, phone2 || null,
|
||||||
|
taxable !== undefined ? taxable : true, remarks || null]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
res.status(500).json({ error: 'Error creating customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT update customer
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
||||||
|
account_number, email, phone, phone2, taxable, remarks
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE customers
|
||||||
|
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
|
||||||
|
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
|
||||||
|
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $16
|
||||||
|
RETURNING *`,
|
||||||
|
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||||
|
city || null, state || null, zip_code || null, account_number || null,
|
||||||
|
email || null, phone || null, phone2 || null,
|
||||||
|
taxable !== undefined ? taxable : true, remarks || null, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const customer = result.rows[0];
|
||||||
|
|
||||||
|
// QBO Update
|
||||||
|
if (customer.qbo_id) {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
// Get SyncToken
|
||||||
|
const qboRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||||
|
const syncToken = qboData.Customer?.SyncToken;
|
||||||
|
|
||||||
|
if (syncToken !== undefined) {
|
||||||
|
const updatePayload = {
|
||||||
|
Id: customer.qbo_id,
|
||||||
|
SyncToken: syncToken,
|
||||||
|
sparse: true,
|
||||||
|
DisplayName: name,
|
||||||
|
CompanyName: name,
|
||||||
|
PrimaryEmailAddr: email ? { Address: email } : undefined,
|
||||||
|
PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined,
|
||||||
|
Taxable: taxable !== false,
|
||||||
|
Notes: remarks || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contact → GivenName / FamilyName
|
||||||
|
if (contact) {
|
||||||
|
const parts = contact.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
updatePayload.GivenName = parts[0];
|
||||||
|
updatePayload.FamilyName = parts.slice(1).join(' ');
|
||||||
|
} else {
|
||||||
|
updatePayload.GivenName = parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address
|
||||||
|
const addr = {};
|
||||||
|
if (line1) addr.Line1 = line1;
|
||||||
|
if (line2) addr.Line2 = line2;
|
||||||
|
if (line3) addr.Line3 = line3;
|
||||||
|
if (line4) addr.Line4 = line4;
|
||||||
|
if (city) addr.City = city;
|
||||||
|
if (state) addr.CountrySubDivisionCode = state;
|
||||||
|
if (zip_code) addr.PostalCode = zip_code;
|
||||||
|
if (Object.keys(addr).length > 0) updatePayload.BillAddr = addr;
|
||||||
|
|
||||||
|
console.log(`📤 Updating QBO Customer ${customer.qbo_id} (${name})...`);
|
||||||
|
|
||||||
|
await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ QBO Customer ${customer.qbo_id} updated.`);
|
||||||
|
}
|
||||||
|
} catch (qboError) {
|
||||||
|
console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(customer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error);
|
||||||
|
res.status(500).json({ error: 'Error updating customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE customer
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load customer
|
||||||
|
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||||
|
if (custResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = custResult.rows[0];
|
||||||
|
|
||||||
|
// Deactivate in QBO if present
|
||||||
|
if (customer.qbo_id) {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
// Get SyncToken
|
||||||
|
const qboRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||||
|
const syncToken = qboData.Customer?.SyncToken;
|
||||||
|
|
||||||
|
if (syncToken !== undefined) {
|
||||||
|
console.log(`🗑️ Deactivating QBO Customer ${customer.qbo_id} (${customer.name})...`);
|
||||||
|
|
||||||
|
await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
Id: customer.qbo_id,
|
||||||
|
SyncToken: syncToken,
|
||||||
|
sparse: true,
|
||||||
|
Active: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ QBO Customer ${customer.qbo_id} deactivated.`);
|
||||||
|
}
|
||||||
|
} catch (qboError) {
|
||||||
|
console.error(`⚠️ QBO deactivate failed for Customer ${customer.qbo_id}:`, qboError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete locally
|
||||||
|
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting customer:', error);
|
||||||
|
res.status(500).json({ error: 'Error deleting customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST export customer to QBO
|
||||||
|
router.post('/:id/export-qbo', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||||
|
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
const customer = custResult.rows[0];
|
||||||
|
|
||||||
|
if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' });
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
const qboCustomer = {
|
||||||
|
DisplayName: customer.name,
|
||||||
|
CompanyName: customer.name,
|
||||||
|
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
|
||||||
|
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
|
||||||
|
Taxable: customer.taxable !== false,
|
||||||
|
Notes: customer.remarks || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
if (customer.contact) {
|
||||||
|
const parts = customer.contact.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
qboCustomer.GivenName = parts[0];
|
||||||
|
qboCustomer.FamilyName = parts.slice(1).join(' ');
|
||||||
|
} else {
|
||||||
|
qboCustomer.GivenName = parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address
|
||||||
|
const addr = {};
|
||||||
|
if (customer.line1) addr.Line1 = customer.line1;
|
||||||
|
if (customer.line2) addr.Line2 = customer.line2;
|
||||||
|
if (customer.line3) addr.Line3 = customer.line3;
|
||||||
|
if (customer.line4) addr.Line4 = customer.line4;
|
||||||
|
if (customer.city) addr.City = customer.city;
|
||||||
|
if (customer.state) addr.CountrySubDivisionCode = customer.state;
|
||||||
|
if (customer.zip_code) addr.PostalCode = customer.zip_code;
|
||||||
|
if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr;
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(qboCustomer)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const qboId = data.Customer?.Id;
|
||||||
|
|
||||||
|
if (!qboId) throw new Error('QBO returned no ID');
|
||||||
|
|
||||||
|
await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]);
|
||||||
|
|
||||||
|
console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`);
|
||||||
|
res.json({ success: true, qbo_id: qboId, name: customer.name });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QBO Customer Export Error:', error);
|
||||||
|
res.status(500).json({ error: 'Export failed: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
1163
src/routes/invoices.js
Normal file
1163
src/routes/invoices.js
Normal file
File diff suppressed because it is too large
Load Diff
29
src/routes/payments.js
Normal file
29
src/routes/payments.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Payment Routes
|
||||||
|
* Handles payment recording and listing
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
|
||||||
|
// GET all payments
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT p.*, c.name as customer_name,
|
||||||
|
COALESCE(json_agg(json_build_object(
|
||||||
|
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
|
||||||
|
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN customers c ON p.customer_id = c.id
|
||||||
|
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
||||||
|
LEFT JOIN invoices i ON i.id = pi.invoice_id
|
||||||
|
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
|
||||||
|
`);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Error fetching payments' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
572
src/routes/qbo.js
Normal file
572
src/routes/qbo.js
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
/**
|
||||||
|
* QBO Routes
|
||||||
|
* Handles QBO sync and data operations
|
||||||
|
* NOTE: OAuth auth/callback routes are in index.js (root-level paths)
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
|
|
||||||
|
// GET QBO status
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
const token = client.getToken();
|
||||||
|
const hasToken = !!(token && token.refresh_token);
|
||||||
|
res.json({
|
||||||
|
connected: hasToken,
|
||||||
|
realmId: token?.realmId || null
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ connected: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET bank accounts from QBO
|
||||||
|
router.get('/accounts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET payment methods from QBO
|
||||||
|
router.get('/payment-methods', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET labor rate from QBO
|
||||||
|
router.get('/labor-rate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/item/5`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const rate = data.Item?.UnitPrice || null;
|
||||||
|
|
||||||
|
console.log(`💰 QBO Labor Rate: $${rate}`);
|
||||||
|
res.json({ rate });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching labor rate:', error);
|
||||||
|
res.json({ rate: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET last sync timestamp
|
||||||
|
router.get('/last-sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
|
||||||
|
res.json({ last_sync: result.rows[0]?.value || null });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ last_sync: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET overdue invoices from QBO
|
||||||
|
router.get('/overdue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - 30);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
|
||||||
|
|
||||||
|
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const invoices = data.QueryResponse?.Invoice || [];
|
||||||
|
|
||||||
|
console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`);
|
||||||
|
res.json(invoices);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("QBO Report Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST import unpaid invoices from QBO
|
||||||
|
router.post('/import-unpaid', async (req, res) => {
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
console.log('📥 QBO Import: Lade unbezahlte Rechnungen...');
|
||||||
|
|
||||||
|
const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000";
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const qboInvoices = data.QueryResponse?.Invoice || [];
|
||||||
|
|
||||||
|
console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`);
|
||||||
|
|
||||||
|
if (qboInvoices.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
skippedNoCustomer: 0,
|
||||||
|
message: 'Keine unbezahlten Rechnungen in QBO gefunden.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load local customers
|
||||||
|
const customersResult = await dbClient.query(
|
||||||
|
'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
const customerMap = new Map();
|
||||||
|
customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c));
|
||||||
|
|
||||||
|
// Get already imported QBO invoices
|
||||||
|
const existingResult = await dbClient.query(
|
||||||
|
'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id));
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let skippedNoCustomer = 0;
|
||||||
|
const skippedCustomerNames = [];
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
for (const qboInv of qboInvoices) {
|
||||||
|
const qboId = String(qboInv.Id);
|
||||||
|
|
||||||
|
if (existingQboIds.has(qboId)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerQboId = String(qboInv.CustomerRef?.value || '');
|
||||||
|
const localCustomer = customerMap.get(customerQboId);
|
||||||
|
|
||||||
|
if (!localCustomer) {
|
||||||
|
skippedNoCustomer++;
|
||||||
|
const custName = qboInv.CustomerRef?.name || 'Unbekannt';
|
||||||
|
if (!skippedCustomerNames.includes(custName)) {
|
||||||
|
skippedCustomerNames.push(custName);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docNumber = qboInv.DocNumber || '';
|
||||||
|
const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0];
|
||||||
|
const syncToken = qboInv.SyncToken || '';
|
||||||
|
|
||||||
|
let terms = 'Net 30';
|
||||||
|
if (qboInv.SalesTermRef?.name) {
|
||||||
|
terms = qboInv.SalesTermRef.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0;
|
||||||
|
const taxExempt = taxAmount === 0;
|
||||||
|
|
||||||
|
const total = parseFloat(qboInv.TotalAmt) || 0;
|
||||||
|
const subtotal = total - taxAmount;
|
||||||
|
const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25;
|
||||||
|
|
||||||
|
const authCode = qboInv.CustomerMemo?.value || '';
|
||||||
|
|
||||||
|
const invoiceResult = await dbClient.query(
|
||||||
|
`INSERT INTO invoices
|
||||||
|
(invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||||
|
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||||
|
qbo_id, qbo_sync_token, qbo_doc_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING id`,
|
||||||
|
[docNumber, localCustomer.id, txnDate, terms, authCode,
|
||||||
|
taxExempt, taxRate, subtotal, taxAmount, total,
|
||||||
|
qboId, syncToken, docNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
const localInvoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
|
const lines = qboInv.Line || [];
|
||||||
|
let itemOrder = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.DetailType !== 'SalesItemLineDetail') continue;
|
||||||
|
|
||||||
|
const detail = line.SalesItemLineDetail || {};
|
||||||
|
const qty = String(detail.Qty || 1);
|
||||||
|
const rate = String(detail.UnitPrice || 0);
|
||||||
|
const amount = String(line.Amount || 0);
|
||||||
|
const description = line.Description || '';
|
||||||
|
|
||||||
|
const itemRefValue = detail.ItemRef?.value || '9';
|
||||||
|
const itemRefName = (detail.ItemRef?.name || '').toLowerCase();
|
||||||
|
let qboItemId = '9';
|
||||||
|
if (itemRefValue === '5' || itemRefName.includes('labor')) {
|
||||||
|
qboItemId = '5';
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
`INSERT INTO invoice_items
|
||||||
|
(invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId]
|
||||||
|
);
|
||||||
|
itemOrder++;
|
||||||
|
}
|
||||||
|
|
||||||
|
imported++;
|
||||||
|
console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`${imported} Rechnungen importiert.`,
|
||||||
|
skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '',
|
||||||
|
skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : ''
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
console.log(`📥 QBO Import abgeschlossen: ${message}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
skippedNoCustomer,
|
||||||
|
skippedCustomerNames,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK');
|
||||||
|
console.error('❌ QBO Import Error:', error);
|
||||||
|
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST record payment in QBO
|
||||||
|
router.post('/record-payment', async (req, res) => {
|
||||||
|
const {
|
||||||
|
invoice_payments,
|
||||||
|
payment_date,
|
||||||
|
reference_number,
|
||||||
|
payment_method_id,
|
||||||
|
payment_method_name,
|
||||||
|
deposit_to_account_id,
|
||||||
|
deposit_to_account_name
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!invoice_payments || invoice_payments.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No invoices selected.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
const ids = invoice_payments.map(ip => ip.invoice_id);
|
||||||
|
const result = await dbClient.query(
|
||||||
|
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||||
|
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = ANY($1)`, [ids]
|
||||||
|
);
|
||||||
|
const invoicesData = result.rows;
|
||||||
|
|
||||||
|
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||||
|
if (notInQbo.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||||
|
if (custIds.length > 1) {
|
||||||
|
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
|
||||||
|
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
|
||||||
|
|
||||||
|
const qboPayment = {
|
||||||
|
CustomerRef: { value: custIds[0] },
|
||||||
|
TotalAmt: totalAmt,
|
||||||
|
TxnDate: payment_date,
|
||||||
|
PaymentRefNum: reference_number || '',
|
||||||
|
PaymentMethodRef: { value: payment_method_id },
|
||||||
|
DepositToAccountRef: { value: deposit_to_account_id },
|
||||||
|
Line: invoicesData.map(inv => ({
|
||||||
|
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
|
||||||
|
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(qboPayment)
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
|
if (!data.Payment) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const qboPaymentId = data.Payment.Id;
|
||||||
|
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
const payResult = await dbClient.query(
|
||||||
|
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
|
[payment_date, reference_number || null, payment_method_name || 'Check',
|
||||||
|
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
|
||||||
|
);
|
||||||
|
const localPaymentId = payResult.rows[0].id;
|
||||||
|
|
||||||
|
for (const ip of invoice_payments) {
|
||||||
|
const payAmt = parseFloat(ip.amount);
|
||||||
|
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
||||||
|
const invTotal = inv ? parseFloat(inv.total) : 0;
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
|
[localPaymentId, ip.invoice_id, payAmt]
|
||||||
|
);
|
||||||
|
if (payAmt >= invTotal) {
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[payment_date, ip.invoice_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
payment_id: localPaymentId,
|
||||||
|
qbo_payment_id: qboPaymentId,
|
||||||
|
total: totalAmt,
|
||||||
|
invoices_paid: invoice_payments.length,
|
||||||
|
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('❌ Payment Error:', error);
|
||||||
|
res.status(500).json({ error: 'Payment failed: ' + error.message });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST sync payments from QBO
|
||||||
|
router.post('/sync-payments', async (req, res) => {
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
try {
|
||||||
|
const openResult = await dbClient.query(`
|
||||||
|
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
|
||||||
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
|
||||||
|
FROM invoices i
|
||||||
|
WHERE i.qbo_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
const openInvoices = openResult.rows;
|
||||||
|
if (openInvoices.length === 0) {
|
||||||
|
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
|
||||||
|
return res.json({ synced: 0, message: 'All invoices up to date.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
const batchSize = 50;
|
||||||
|
const qboInvoices = new Map();
|
||||||
|
|
||||||
|
for (let i = 0; i < openInvoices.length; i += batchSize) {
|
||||||
|
const batch = openInvoices.slice(i, i + batchSize);
|
||||||
|
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
||||||
|
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const invoices = data.QueryResponse?.Invoice || [];
|
||||||
|
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let newPayments = 0;
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
for (const localInv of openInvoices) {
|
||||||
|
const qboInv = qboInvoices.get(localInv.qbo_id);
|
||||||
|
if (!qboInv) continue;
|
||||||
|
|
||||||
|
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
||||||
|
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
||||||
|
const localPaid = parseFloat(localInv.local_paid) || 0;
|
||||||
|
|
||||||
|
if (qboBalance === 0 && qboTotal > 0) {
|
||||||
|
const UNDEPOSITED_FUNDS_ID = '221';
|
||||||
|
let status = 'Paid';
|
||||||
|
|
||||||
|
if (qboInv.LinkedTxn) {
|
||||||
|
for (const txn of qboInv.LinkedTxn) {
|
||||||
|
if (txn.TxnType === 'Payment') {
|
||||||
|
try {
|
||||||
|
const pmRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||||
|
const payment = pmData.Payment;
|
||||||
|
if (payment && payment.DepositToAccountRef &&
|
||||||
|
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
|
||||||
|
status = 'Deposited';
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
|
||||||
|
if (needsUpdate) {
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE invoices SET
|
||||||
|
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
||||||
|
payment_status = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[status, localInv.id]
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = qboTotal - localPaid;
|
||||||
|
if (diff > 0.01) {
|
||||||
|
const payResult = await dbClient.query(
|
||||||
|
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||||
|
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id`,
|
||||||
|
[diff, localInv.id]
|
||||||
|
);
|
||||||
|
await dbClient.query(
|
||||||
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
|
[payResult.rows[0].id, localInv.id, diff]
|
||||||
|
);
|
||||||
|
newPayments++;
|
||||||
|
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
||||||
|
const qboPaid = qboTotal - qboBalance;
|
||||||
|
const diff = qboPaid - localPaid;
|
||||||
|
|
||||||
|
const needsUpdate = localInv.payment_status !== 'Partial';
|
||||||
|
if (needsUpdate) {
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
['Partial', localInv.id]
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff > 0.01) {
|
||||||
|
const payResult = await dbClient.query(
|
||||||
|
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||||
|
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id`,
|
||||||
|
[diff, localInv.id]
|
||||||
|
);
|
||||||
|
await dbClient.query(
|
||||||
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
|
[payResult.rows[0].id, localInv.id, diff]
|
||||||
|
);
|
||||||
|
newPayments++;
|
||||||
|
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query(`
|
||||||
|
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $1
|
||||||
|
`, [new Date().toISOString()]);
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
||||||
|
res.json({
|
||||||
|
synced: updated,
|
||||||
|
new_payments: newPayments,
|
||||||
|
total_checked: openInvoices.length,
|
||||||
|
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('❌ Sync Error:', error);
|
||||||
|
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
370
src/routes/quotes.js
Normal file
370
src/routes/quotes.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* Quote Routes
|
||||||
|
* Handles quote CRUD operations and PDF generation
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
const { getNextQuoteNumber } = require('../utils/numberGenerators');
|
||||||
|
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||||
|
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderQuoteItems, formatAddressLines } = require('../services/pdf-service');
|
||||||
|
|
||||||
|
// GET all quotes
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT q.*, c.name as customer_name
|
||||||
|
FROM quotes q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
`);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching quotes:', error);
|
||||||
|
res.status(500).json({ error: 'Error fetching quotes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET single quote
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const quoteResult = await pool.query(`
|
||||||
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
|
FROM quotes q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (quoteResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
quote: quoteResult.rows[0],
|
||||||
|
items: itemsResult.rows
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching quote:', error);
|
||||||
|
res.status(500).json({ error: 'Error fetching quote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST create quote
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const quote_number = await getNextQuoteNumber();
|
||||||
|
|
||||||
|
let subtotal = 0;
|
||||||
|
let has_tbd = false;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||||
|
has_tbd = true;
|
||||||
|
} else {
|
||||||
|
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||||
|
if (!isNaN(amount)) {
|
||||||
|
subtotal += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax_rate = 8.25;
|
||||||
|
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||||
|
const total = subtotal + tax_amount;
|
||||||
|
|
||||||
|
const quoteResult = await client.query(
|
||||||
|
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||||
|
[quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd]
|
||||||
|
);
|
||||||
|
|
||||||
|
const quoteId = quoteResult.rows[0].id;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json(quoteResult.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error creating quote:', error);
|
||||||
|
res.status(500).json({ error: 'Error creating quote' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT update quote
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
let subtotal = 0;
|
||||||
|
let has_tbd = false;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||||
|
has_tbd = true;
|
||||||
|
} else {
|
||||||
|
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||||
|
if (!isNaN(amount)) {
|
||||||
|
subtotal += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax_rate = 8.25;
|
||||||
|
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||||
|
const total = subtotal + tax_amount;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4,
|
||||||
|
subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $9`,
|
||||||
|
[customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error updating quote:', error);
|
||||||
|
res.status(500).json({ error: 'Error updating quote' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE quote
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||||
|
await client.query('DELETE FROM quotes WHERE id = $1', [id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error deleting quote:', error);
|
||||||
|
res.status(500).json({ error: 'Error deleting quote' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET quote PDF
|
||||||
|
router.get('/:id/pdf', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quoteResult = await pool.query(`
|
||||||
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
|
FROM quotes q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (quoteResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = quoteResult.rows[0];
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||||
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
|
const logoHTML = await getLogoHtml();
|
||||||
|
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||||
|
|
||||||
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||||
|
|
||||||
|
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||||
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||||
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||||
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||||
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||||
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||||
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||||
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||||
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
|
.replace('{{TBD_NOTE}}', tbdNote);
|
||||||
|
|
||||||
|
const pdf = await generatePdfFromHtml(html);
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Length': pdf.length,
|
||||||
|
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
|
||||||
|
});
|
||||||
|
res.end(pdf, 'binary');
|
||||||
|
console.log('[PDF] Quote PDF sent successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PDF] ERROR:', error);
|
||||||
|
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET quote HTML (debug)
|
||||||
|
router.get('/:id/html', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quoteResult = await pool.query(`
|
||||||
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
|
FROM quotes q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (quoteResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = quoteResult.rows[0];
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||||
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
|
const logoHTML = await getLogoHtml();
|
||||||
|
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||||
|
|
||||||
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||||
|
|
||||||
|
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||||
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||||
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||||
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||||
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||||
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||||
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||||
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||||
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
|
.replace('{{TBD_NOTE}}', tbdNote);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(html);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HTML] ERROR:', error);
|
||||||
|
res.status(500).json({ error: 'Error generating HTML' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST convert quote to invoice
|
||||||
|
router.post('/:id/convert-to-invoice', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const quoteResult = await pool.query(`
|
||||||
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
|
FROM quotes q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (quoteResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = quoteResult.rows[0];
|
||||||
|
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTBD = itemsResult.rows.some(item =>
|
||||||
|
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasTBD) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice_number = null;
|
||||||
|
const invoiceDate = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const invoiceResult = await client.query(
|
||||||
|
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||||
|
[invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||||
|
const item = itemsResult.rows[i];
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json(invoiceResult.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error converting quote to invoice:', error);
|
||||||
|
res.status(500).json({ error: 'Error converting quote to invoice' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
71
src/routes/settings.js
Normal file
71
src/routes/settings.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Settings Routes
|
||||||
|
* Handles logo upload and settings
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
|
// Configure multer for logo upload
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads');
|
||||||
|
try {
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating upload directory:', err);
|
||||||
|
}
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
cb(null, 'company-logo.png');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET logo info
|
||||||
|
router.get('/logo-info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
|
||||||
|
try {
|
||||||
|
await fs.access(logoPath);
|
||||||
|
res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' });
|
||||||
|
} catch {
|
||||||
|
res.json({ hasLogo: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking logo:', error);
|
||||||
|
res.status(500).json({ error: 'Error checking logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST upload logo
|
||||||
|
router.post('/upload-logo', upload.single('logo'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
message: 'Logo uploaded successfully',
|
||||||
|
path: '/uploads/company-logo.png'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
res.status(500).json({ error: 'Error uploading logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
134
src/services/email-service.js
Normal file
134
src/services/email-service.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// src/services/email-service.js
|
||||||
|
const { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const mjml2html = require('mjml');
|
||||||
|
|
||||||
|
const sesClient = new SESv2Client({
|
||||||
|
region: process.env.AWS_REGION || 'us-east-2'
|
||||||
|
});
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
SES: {
|
||||||
|
sesClient,
|
||||||
|
SendEmailCommand
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) {
|
||||||
|
const formattedText = customText || '';
|
||||||
|
|
||||||
|
// Stripe Pay Button — only if payment link exists
|
||||||
|
let paymentButtonMjml = '';
|
||||||
|
if (stripePaymentUrl) {
|
||||||
|
paymentButtonMjml = `
|
||||||
|
<mj-section background-color="#ffffff" padding="0 30px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-button
|
||||||
|
background-color="#635bff"
|
||||||
|
color="white"
|
||||||
|
border-radius="6px"
|
||||||
|
href="${stripePaymentUrl}"
|
||||||
|
font-weight="600"
|
||||||
|
font-size="16px"
|
||||||
|
padding="25px 0 10px 0"
|
||||||
|
inner-padding="14px 30px"
|
||||||
|
width="100%">
|
||||||
|
Pay Online — Credit Card or ACH
|
||||||
|
</mj-button>
|
||||||
|
<mj-text font-size="12px" color="#94a3b8" align="center" padding="0 0 20px 0">
|
||||||
|
ACH payments incur lower processing fees. Secure payment powered by Stripe.
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = `
|
||||||
|
<mjml>
|
||||||
|
<mj-head>
|
||||||
|
<mj-attributes>
|
||||||
|
<mj-all font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" />
|
||||||
|
</mj-attributes>
|
||||||
|
<mj-style inline="inline">
|
||||||
|
.email-body p {
|
||||||
|
margin: 0 0 14px 0 !important;
|
||||||
|
}
|
||||||
|
.email-body p:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
</mj-style>
|
||||||
|
</mj-head>
|
||||||
|
<mj-body background-color="#f4f4f5">
|
||||||
|
|
||||||
|
<mj-section padding="0">
|
||||||
|
<mj-column>
|
||||||
|
<mj-spacer height="20px" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section background-color="#ffffff" padding="30px" border-radius="8px 8px 0 0">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text font-size="22px" font-weight="700" color="#1e3a8a" padding="0">
|
||||||
|
Bay Area Affiliates, Inc.
|
||||||
|
</mj-text>
|
||||||
|
<mj-text font-size="15px" color="#64748b" padding="5px 0 0 0">
|
||||||
|
Invoice #${invoice.invoice_number || invoice.id}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section background-color="#ffffff" padding="0 30px 30px 30px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
|
||||||
|
${formattedText}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
${paymentButtonMjml}
|
||||||
|
|
||||||
|
<mj-section background-color="#ffffff" padding="0 30px 30px 30px" border-radius="0 0 8px 8px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="10px" padding-bottom="20px" />
|
||||||
|
|
||||||
|
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
|
||||||
|
<strong>Prefer to pay by check?</strong><br/>
|
||||||
|
Please make checks payable to Bay Area Affiliates, Inc. and mail to:<br/>
|
||||||
|
1001 Blucher Street<br/>
|
||||||
|
Corpus Christi, Texas 78401
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-body>
|
||||||
|
</mjml>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = mjml2html(template, { validationLevel: 'strict' });
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
console.error('MJML Parse Errors:', result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePaymentUrl, pdfBuffer) {
|
||||||
|
const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl);
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
||||||
|
html: htmlContent,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: `Invoice_${invoice.invoice_number || invoice.id}_BayAreaAffiliates.pdf`,
|
||||||
|
content: pdfBuffer,
|
||||||
|
contentType: 'application/pdf'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return await transporter.sendMail(mailOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendInvoiceEmail };
|
||||||
206
src/services/pdf-service.js
Normal file
206
src/services/pdf-service.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* PDF Generation Service
|
||||||
|
* Handles HTML to PDF conversion using Puppeteer
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { formatMoney, formatDate } = require('../utils/helpers');
|
||||||
|
|
||||||
|
// Initialize browser - will be set from main app
|
||||||
|
let browserInstance = null;
|
||||||
|
|
||||||
|
function setBrowser(browser) {
|
||||||
|
browserInstance = browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBrowser() {
|
||||||
|
return browserInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PDF from HTML template
|
||||||
|
*/
|
||||||
|
async function generatePdfFromHtml(html, options = {}) {
|
||||||
|
const {
|
||||||
|
format = 'Letter',
|
||||||
|
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
||||||
|
printBackground = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const browser = await getBrowser();
|
||||||
|
if (!browser) {
|
||||||
|
throw new Error('Browser not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
//await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.setContent(html, { waitUntil: 'load', timeout: 5000 });
|
||||||
|
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format,
|
||||||
|
printBackground,
|
||||||
|
margin
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
return pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get company logo as base64 HTML
|
||||||
|
*/
|
||||||
|
async function getLogoHtml() {
|
||||||
|
let logoHTML = '';
|
||||||
|
try {
|
||||||
|
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
|
||||||
|
const logoData = await fs.readFile(logoPath);
|
||||||
|
const logoBase64 = logoData.toString('base64');
|
||||||
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||||
|
} catch (err) {
|
||||||
|
// No logo found
|
||||||
|
}
|
||||||
|
return logoHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render invoice items to HTML table rows
|
||||||
|
*/
|
||||||
|
function renderInvoiceItems(items, invoice = null) {
|
||||||
|
let itemsHTML = items.map(item => {
|
||||||
|
let rateFormatted = item.rate;
|
||||||
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="qty">${item.quantity}</td>
|
||||||
|
<td class="description">${item.description}</td>
|
||||||
|
<td class="rate">${rateFormatted}</td>
|
||||||
|
<td class="amount">${item.amount}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add subtotal
|
||||||
|
const subtotal = invoice ? invoice.subtotal : 0;
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
|
<td class="total-amount">$${formatMoney(subtotal)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// Add tax if not exempt
|
||||||
|
if (invoice && !invoice.tax_exempt) {
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
|
||||||
|
<td class="total-amount">$${formatMoney(invoice.tax_amount)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add total
|
||||||
|
const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0;
|
||||||
|
const total = invoice ? parseFloat(invoice.total) : 0;
|
||||||
|
const balanceDue = total - amountPaid;
|
||||||
|
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL:</td>
|
||||||
|
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// Add downpayment/balance if partial
|
||||||
|
if (amountPaid > 0) {
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label" style="color: #059669;">Downpayment:</td>
|
||||||
|
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">BALANCE DUE:</td>
|
||||||
|
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thank you message
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return itemsHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render quote items to HTML table rows
|
||||||
|
*/
|
||||||
|
function renderQuoteItems(items, quote = null) {
|
||||||
|
let itemsHTML = items.map(item => {
|
||||||
|
let rateFormatted = item.rate;
|
||||||
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="qty">${item.quantity}</td>
|
||||||
|
<td class="description">${item.description}</td>
|
||||||
|
<td class="rate">${rateFormatted}</td>
|
||||||
|
<td class="amount">${item.amount}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add subtotal
|
||||||
|
const subtotal = quote ? quote.subtotal : 0;
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Subtotal</td>
|
||||||
|
<td class="total-amount">$${formatMoney(subtotal)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// Add tax if not exempt
|
||||||
|
if (quote && !quote.tax_exempt) {
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%)</td>
|
||||||
|
<td class="total-amount">$${formatMoney(quote.tax_amount)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add total
|
||||||
|
const total = quote ? quote.total : 0;
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL</td>
|
||||||
|
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="4" class="thank-you">This quote is valid for 14 days. We appreciate your business </td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return itemsHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format address lines for template
|
||||||
|
*/
|
||||||
|
function formatAddressLines(line1, line2, line3, line4, customerName) {
|
||||||
|
const addressLines = [];
|
||||||
|
if (line1 && line1.trim().toLowerCase() !== (customerName || '').trim().toLowerCase()) {
|
||||||
|
addressLines.push(line1);
|
||||||
|
}
|
||||||
|
if (line2) addressLines.push(line2);
|
||||||
|
if (line3) addressLines.push(line3);
|
||||||
|
if (line4) addressLines.push(line4);
|
||||||
|
return addressLines.join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
setBrowser,
|
||||||
|
getBrowser,
|
||||||
|
generatePdfFromHtml,
|
||||||
|
getLogoHtml,
|
||||||
|
renderInvoiceItems,
|
||||||
|
renderQuoteItems,
|
||||||
|
formatAddressLines
|
||||||
|
};
|
||||||
222
src/services/qbo-service.js
Normal file
222
src/services/qbo-service.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// src/services/qbo-service.js
|
||||||
|
/**
|
||||||
|
* QuickBooks Online Service
|
||||||
|
* Handles QBO API interactions
|
||||||
|
*/
|
||||||
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
|
||||||
|
|
||||||
|
// QBO Item IDs
|
||||||
|
const QBO_LABOR_ID = '5';
|
||||||
|
const QBO_PARTS_ID = '9';
|
||||||
|
|
||||||
|
function getClientInfo() {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
return { oauthClient, companyId, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export invoice to QBO
|
||||||
|
*/
|
||||||
|
async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||||
|
const invoiceRes = await dbClient.query(`
|
||||||
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [invoiceId]);
|
||||||
|
|
||||||
|
const invoice = invoiceRes.rows[0];
|
||||||
|
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
||||||
|
|
||||||
|
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||||
|
const items = itemsRes.rows;
|
||||||
|
|
||||||
|
const { companyId, baseUrl } = getClientInfo();
|
||||||
|
|
||||||
|
// Get next DocNumber
|
||||||
|
const maxNumResult = await dbClient.query(`
|
||||||
|
SELECT GREATEST(
|
||||||
|
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
|
||||||
|
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
|
||||||
|
) as max_num
|
||||||
|
`);
|
||||||
|
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
||||||
|
|
||||||
|
const lineItems = items.map(item => {
|
||||||
|
const parseNum = (val) => {
|
||||||
|
if (val === null || val === undefined) return 0;
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||||
|
};
|
||||||
|
const rate = parseNum(item.rate);
|
||||||
|
const qty = parseNum(item.quantity) || 1;
|
||||||
|
const amount = rate * qty;
|
||||||
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||||
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||||
|
|
||||||
|
return {
|
||||||
|
"DetailType": "SalesItemLineDetail",
|
||||||
|
"Amount": amount,
|
||||||
|
"Description": item.description,
|
||||||
|
"SalesItemLineDetail": {
|
||||||
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
|
"UnitPrice": rate,
|
||||||
|
"Qty": qty
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const qboPayload = {
|
||||||
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||||
|
"DocNumber": nextDocNumber,
|
||||||
|
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||||||
|
"Line": lineItems,
|
||||||
|
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
|
||||||
|
"EmailStatus": "NotSet",
|
||||||
|
"BillEmail": { "Address": invoice.email || "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
let qboInvoice = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`);
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(qboPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
|
if (data.Fault?.Error?.[0]?.code === '6140') {
|
||||||
|
console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
|
||||||
|
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (data.Fault) {
|
||||||
|
const errMsg = data.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(data.Fault);
|
||||||
|
console.error(`❌ QBO Export Fault:`, errMsg);
|
||||||
|
throw new Error('QBO export failed: ' + errMsg);
|
||||||
|
}
|
||||||
|
qboInvoice = data.Invoice || data;
|
||||||
|
if (qboInvoice.Id) break;
|
||||||
|
|
||||||
|
throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5',
|
||||||
|
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`);
|
||||||
|
return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync invoice to QBO (update)
|
||||||
|
*/
|
||||||
|
async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||||
|
const invoiceRes = await dbClient.query(`
|
||||||
|
SELECT i.*, c.qbo_id as customer_qbo_id
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [invoiceId]);
|
||||||
|
|
||||||
|
const invoice = invoiceRes.rows[0];
|
||||||
|
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
|
||||||
|
|
||||||
|
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||||
|
|
||||||
|
const { companyId, baseUrl } = getClientInfo();
|
||||||
|
|
||||||
|
const qboRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||||
|
const currentSyncToken = qboData.Invoice?.SyncToken;
|
||||||
|
|
||||||
|
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
|
||||||
|
|
||||||
|
const lineItems = itemsRes.rows.map(item => {
|
||||||
|
const parseNum = (val) => {
|
||||||
|
if (val === null || val === undefined) return 0;
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||||
|
};
|
||||||
|
const rate = parseNum(item.rate);
|
||||||
|
const qty = parseNum(item.quantity) || 1;
|
||||||
|
const amount = rate * qty;
|
||||||
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||||
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||||
|
|
||||||
|
return {
|
||||||
|
"DetailType": "SalesItemLineDetail",
|
||||||
|
"Amount": amount,
|
||||||
|
"Description": item.description,
|
||||||
|
"SalesItemLineDetail": {
|
||||||
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
|
"UnitPrice": rate,
|
||||||
|
"Qty": qty
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
"Id": invoice.qbo_id,
|
||||||
|
"SyncToken": currentSyncToken,
|
||||||
|
"sparse": true,
|
||||||
|
"Line": lineItems,
|
||||||
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||||
|
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||||||
|
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
|
||||||
|
|
||||||
|
const updateRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
|
||||||
|
|
||||||
|
if (updateData.Fault) {
|
||||||
|
const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
|
||||||
|
console.error(`❌ QBO Sync Fault:`, errMsg);
|
||||||
|
throw new Error('QBO sync failed: ' + errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateData.Invoice || updateData;
|
||||||
|
|
||||||
|
if (!updated.Id) {
|
||||||
|
throw new Error('QBO update returned no ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[updated.SyncToken, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
|
||||||
|
return { success: true, sync_token: updated.SyncToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
QBO_LABOR_ID,
|
||||||
|
QBO_PARTS_ID,
|
||||||
|
getClientInfo,
|
||||||
|
exportInvoiceToQbo,
|
||||||
|
syncInvoiceToQbo
|
||||||
|
};
|
||||||
174
src/services/recurring-service.js
Normal file
174
src/services/recurring-service.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Recurring Invoice Service
|
||||||
|
* Checks daily for recurring invoices that are due and creates new copies.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - Runs every 24h (and once on startup after 60s delay)
|
||||||
|
* - Finds invoices where is_recurring=true AND next_recurring_date <= today
|
||||||
|
* - Creates a copy with updated invoice_date = next_recurring_date
|
||||||
|
* - Advances next_recurring_date by the interval (monthly/yearly)
|
||||||
|
* - Auto-exports to QBO if customer is linked
|
||||||
|
*/
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
const { exportInvoiceToQbo } = require('./qbo-service');
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const STARTUP_DELAY_MS = 60 * 1000; // 60 seconds after boot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate next date based on interval
|
||||||
|
*/
|
||||||
|
function advanceDate(dateStr, interval) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (interval === 'monthly') {
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
} else if (interval === 'yearly') {
|
||||||
|
d.setFullYear(d.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all due recurring invoices
|
||||||
|
*/
|
||||||
|
async function processRecurringInvoices() {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
console.log(`🔄 [RECURRING] Checking for due recurring invoices (today: ${today})...`);
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
// Find all recurring invoices that are due
|
||||||
|
const dueResult = await client.query(`
|
||||||
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.is_recurring = true
|
||||||
|
AND i.next_recurring_date IS NOT NULL
|
||||||
|
AND i.next_recurring_date <= $1
|
||||||
|
`, [today]);
|
||||||
|
|
||||||
|
if (dueResult.rows.length === 0) {
|
||||||
|
console.log('🔄 [RECURRING] No recurring invoices due.');
|
||||||
|
return { created: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔄 [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const source of dueResult.rows) {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load items from the source invoice
|
||||||
|
const itemsResult = await client.query(
|
||||||
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||||||
|
[source.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Create the new invoice (no invoice_number — QBO will assign one)
|
||||||
|
const newInvoice = await client.query(
|
||||||
|
`INSERT INTO invoices (
|
||||||
|
invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||||
|
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||||
|
bill_to_name, recurring_source_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
`DRAFT-${Date.now()}`, // Temporary, QBO export will assign real number
|
||||||
|
source.customer_id,
|
||||||
|
newInvoiceDate,
|
||||||
|
source.terms,
|
||||||
|
source.auth_code,
|
||||||
|
source.tax_exempt,
|
||||||
|
source.tax_rate,
|
||||||
|
source.subtotal,
|
||||||
|
source.tax_amount,
|
||||||
|
source.total,
|
||||||
|
source.bill_to_name,
|
||||||
|
source.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newInvoiceId = newInvoice.rows[0].id;
|
||||||
|
|
||||||
|
// Copy items
|
||||||
|
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||||
|
const item = itemsResult.rows[i];
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[newInvoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the source invoice's next_recurring_date
|
||||||
|
const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval);
|
||||||
|
await client.query(
|
||||||
|
'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[nextDate, source.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(` ✅ Created recurring invoice from #${source.invoice_number || source.id} → new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`);
|
||||||
|
|
||||||
|
// Auto-export to QBO (outside transaction, non-blocking)
|
||||||
|
try {
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
try {
|
||||||
|
const qboResult = await exportInvoiceToQbo(newInvoiceId, dbClient);
|
||||||
|
if (qboResult.success) {
|
||||||
|
console.log(` 📤 Auto-exported to QBO: #${qboResult.qbo_doc_number}`);
|
||||||
|
} else if (qboResult.skipped) {
|
||||||
|
console.log(` ℹ️ QBO export skipped: ${qboResult.reason}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
} catch (qboErr) {
|
||||||
|
console.error(` ⚠️ QBO auto-export failed for recurring invoice ${newInvoiceId}:`, qboErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
created++;
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error(` ❌ Failed to create recurring invoice from #${source.invoice_number || source.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔄 [RECURRING] Done. Created ${created} invoice(s).`);
|
||||||
|
return { created };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [RECURRING] Error:', error.message);
|
||||||
|
return { created: 0, error: error.message };
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the recurring invoice scheduler
|
||||||
|
*/
|
||||||
|
function startRecurringScheduler() {
|
||||||
|
// First check after startup delay
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('🔄 [RECURRING] Initial check...');
|
||||||
|
processRecurringInvoices();
|
||||||
|
}, STARTUP_DELAY_MS);
|
||||||
|
|
||||||
|
// Then every 24 hours
|
||||||
|
setInterval(() => {
|
||||||
|
processRecurringInvoices();
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
console.log(`🔄 [RECURRING] Scheduler started (checks every 24h, first check in ${STARTUP_DELAY_MS / 1000}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
processRecurringInvoices,
|
||||||
|
startRecurringScheduler,
|
||||||
|
advanceDate
|
||||||
|
};
|
||||||
173
src/services/stripe-service.js
Normal file
173
src/services/stripe-service.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// src/services/stripe-service.js
|
||||||
|
/**
|
||||||
|
* Stripe Payment Links Service
|
||||||
|
* Creates payment links for invoices, checks payment status via API polling.
|
||||||
|
*
|
||||||
|
* No webhooks needed — the app is not internet-facing.
|
||||||
|
* Status is checked on-demand via checkPaymentStatus().
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Payment Link for an invoice.
|
||||||
|
*
|
||||||
|
* @param {object} invoice - Invoice record from DB
|
||||||
|
* @param {number} invoice.id
|
||||||
|
* @param {string} invoice.invoice_number
|
||||||
|
* @param {number} invoice.total - Total in dollars (e.g. 194.85)
|
||||||
|
* @param {number} invoice.balance - Remaining balance (total - amount_paid)
|
||||||
|
* @param {string} [invoice.customer_name]
|
||||||
|
* @returns {object} { paymentLinkId, paymentLinkUrl }
|
||||||
|
*/
|
||||||
|
async function createPaymentLink(invoice) {
|
||||||
|
const amountDue = parseFloat(invoice.balance ?? invoice.total);
|
||||||
|
if (!amountDue || amountDue <= 0) {
|
||||||
|
throw new Error('Invoice has no balance due.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitAmount = Math.round(amountDue * 100); // Convert dollars to cents
|
||||||
|
const invoiceLabel = `Invoice #${invoice.invoice_number || invoice.id}`;
|
||||||
|
|
||||||
|
console.log(`💳 Creating Stripe Payment Link for ${invoiceLabel} — $${amountDue.toFixed(2)}...`);
|
||||||
|
|
||||||
|
const paymentLink = await stripe.paymentLinks.create({
|
||||||
|
line_items: [{
|
||||||
|
price_data: {
|
||||||
|
currency: 'usd',
|
||||||
|
product_data: {
|
||||||
|
name: invoiceLabel,
|
||||||
|
description: invoice.customer_name
|
||||||
|
? `Bay Area Affiliates, Inc. — ${invoice.customer_name}`
|
||||||
|
: 'Bay Area Affiliates, Inc.'
|
||||||
|
},
|
||||||
|
unit_amount: unitAmount,
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
}],
|
||||||
|
metadata: {
|
||||||
|
invoice_id: String(invoice.id),
|
||||||
|
invoice_number: String(invoice.invoice_number || ''),
|
||||||
|
source: 'quote-invoice-system'
|
||||||
|
},
|
||||||
|
payment_method_types: ['card', 'us_bank_account'],
|
||||||
|
// After payment, show a simple confirmation
|
||||||
|
after_completion: {
|
||||||
|
type: 'hosted_confirmation',
|
||||||
|
hosted_confirmation: {
|
||||||
|
custom_message: `Thank you! Your payment for ${invoiceLabel} has been received. Bay Area Affiliates, Inc. will send a confirmation.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Stripe Payment Link created: ${paymentLink.url}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentLinkId: paymentLink.id,
|
||||||
|
paymentLinkUrl: paymentLink.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check payment status for a Stripe Payment Link.
|
||||||
|
* Polls completed Checkout Sessions associated with the payment link.
|
||||||
|
*
|
||||||
|
* @param {string} paymentLinkId - Stripe Payment Link ID (plink_xxx)
|
||||||
|
* @returns {object} { paid, status, details }
|
||||||
|
*/
|
||||||
|
async function checkPaymentStatus(paymentLinkId) {
|
||||||
|
if (!paymentLinkId) {
|
||||||
|
return { paid: false, status: 'no_link', details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 Checking Stripe payment status for ${paymentLinkId}...`);
|
||||||
|
|
||||||
|
// List checkout sessions created via this payment link
|
||||||
|
const sessions = await stripe.checkout.sessions.list({
|
||||||
|
payment_link: paymentLinkId,
|
||||||
|
limit: 10,
|
||||||
|
expand: ['data.payment_intent']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find a completed/paid session
|
||||||
|
const paidSession = sessions.data.find(s => s.payment_status === 'paid');
|
||||||
|
|
||||||
|
if (paidSession) {
|
||||||
|
const pi = paidSession.payment_intent;
|
||||||
|
const paymentMethod = pi?.payment_method_types?.[0] || 'unknown';
|
||||||
|
const amountReceived = (pi?.amount_received || 0) / 100;
|
||||||
|
const stripeFee = calculateStripeFee(amountReceived, paymentMethod);
|
||||||
|
|
||||||
|
console.log(`✅ Payment found! $${amountReceived.toFixed(2)} via ${paymentMethod}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: true,
|
||||||
|
status: 'paid',
|
||||||
|
details: {
|
||||||
|
sessionId: paidSession.id,
|
||||||
|
paymentIntentId: pi?.id,
|
||||||
|
amountReceived,
|
||||||
|
paymentMethod, // 'card' or 'us_bank_account'
|
||||||
|
customerEmail: paidSession.customer_details?.email,
|
||||||
|
paidAt: new Date(paidSession.created * 1000).toISOString(),
|
||||||
|
stripeFee
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending ACH payments (processing state)
|
||||||
|
const pendingSession = sessions.data.find(s => s.payment_status === 'unpaid' && s.status === 'complete');
|
||||||
|
if (pendingSession) {
|
||||||
|
return {
|
||||||
|
paid: false,
|
||||||
|
status: 'processing',
|
||||||
|
details: { note: 'ACH payment is processing (may take 3-5 business days).' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: false,
|
||||||
|
status: sessions.data.length > 0 ? 'attempted' : 'pending',
|
||||||
|
details: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate a payment link (e.g. when invoice is voided or amount changes).
|
||||||
|
*
|
||||||
|
* @param {string} paymentLinkId
|
||||||
|
*/
|
||||||
|
async function deactivatePaymentLink(paymentLinkId) {
|
||||||
|
if (!paymentLinkId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stripe.paymentLinks.update(paymentLinkId, { active: false });
|
||||||
|
console.log(`🚫 Stripe Payment Link ${paymentLinkId} deactivated.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`⚠️ Could not deactivate payment link ${paymentLinkId}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate estimated Stripe fee for reference/QBO booking.
|
||||||
|
* Card: 2.9% + $0.30
|
||||||
|
* ACH: 0.8%, capped at $5.00
|
||||||
|
*
|
||||||
|
* @param {number} amount - Amount in dollars
|
||||||
|
* @param {string} method - 'card' or 'us_bank_account'
|
||||||
|
* @returns {number} Estimated fee in dollars
|
||||||
|
*/
|
||||||
|
function calculateStripeFee(amount, method) {
|
||||||
|
if (method === 'us_bank_account') {
|
||||||
|
return Math.min(amount * 0.008, 5.00);
|
||||||
|
}
|
||||||
|
// Default: card
|
||||||
|
return (amount * 0.029) + 0.30;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPaymentLink,
|
||||||
|
checkPaymentStatus,
|
||||||
|
deactivatePaymentLink,
|
||||||
|
calculateStripeFee
|
||||||
|
};
|
||||||
38
src/utils/helpers.js
Normal file
38
src/utils/helpers.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Utility helper functions for the Quote & Invoice System
|
||||||
|
*/
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(val) {
|
||||||
|
return parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumericValue(val) {
|
||||||
|
if (val === null || val === undefined) return 0;
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(address) {
|
||||||
|
if (!address) return '';
|
||||||
|
const lines = [];
|
||||||
|
if (address.line1) lines.push(address.line1);
|
||||||
|
if (address.line2) lines.push(address.line2);
|
||||||
|
if (address.line3) lines.push(address.line3);
|
||||||
|
if (address.line4) lines.push(address.line4);
|
||||||
|
return lines.join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
parseNumericValue,
|
||||||
|
formatAddress
|
||||||
|
};
|
||||||
37
src/utils/numberGenerators.js
Normal file
37
src/utils/numberGenerators.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Number generation utilities for quotes and invoices
|
||||||
|
*/
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
|
||||||
|
async function getNextQuoteNumber() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1',
|
||||||
|
[`${year}-%`]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return `${year}-001`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]);
|
||||||
|
const nextNumber = String(lastNumber + 1).padStart(3, '0');
|
||||||
|
return `${year}-${nextNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextInvoiceNumber() {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0 || result.rows[0].max_number === null) {
|
||||||
|
return '110508';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(parseInt(result.rows[0].max_number) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getNextQuoteNumber,
|
||||||
|
getNextInvoiceNumber
|
||||||
|
};
|
||||||
@@ -184,7 +184,6 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding-right: 20px !important;
|
padding-right: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-amount {
|
.total-amount {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -206,6 +205,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -202,6 +202,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user