feat: add vendor invoice management UI and comprehensive tests

UI Components:
- Add vendor invoices page route in vendor_pages.py
- Create invoices.html template with stats cards, invoice table,
  settings tab, and create invoice modal
- Add invoices.js Alpine.js component for CRUD operations,
  PDF download, and settings management
- Add Invoices link to vendor sidebar in Sales section

Unit Tests (35 tests):
- VAT calculation (EU rates, regimes, labels)
- Invoice settings CRUD and number generation
- Invoice retrieval, listing, and pagination
- Status management and validation
- Statistics calculation

Integration Tests (34 tests):
- Settings API endpoints (GET/POST/PUT)
- Stats API endpoint
- Invoice list with filtering and pagination
- Invoice detail retrieval
- Invoice creation from orders
- Status update transitions
- PDF generation endpoints
- Authentication/authorization checks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-24 18:59:39 +01:00
parent 319fba5d39
commit e456ae3c73
6 changed files with 2527 additions and 0 deletions

398
static/vendor/js/invoices.js vendored Normal file
View File

@@ -0,0 +1,398 @@
// static/vendor/js/invoices.js
/**
* Vendor invoice management page logic
*/
console.log('[VENDOR INVOICES] Loading...');
function vendorInvoices() {
console.log('[VENDOR INVOICES] vendorInvoices() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'invoices',
// Tab state
activeTab: 'invoices',
// Loading states
loading: false,
savingSettings: false,
creatingInvoice: false,
downloadingPdf: false,
// Messages
error: '',
successMessage: '',
// Settings
hasSettings: false,
settings: null,
settingsForm: {
company_name: '',
company_address: '',
company_city: '',
company_postal_code: '',
company_country: 'LU',
vat_number: '',
invoice_prefix: 'INV',
default_vat_rate: '17.00',
bank_name: '',
bank_iban: '',
bank_bic: '',
payment_terms: 'Net 30 days',
footer_text: ''
},
// Stats
stats: {
total_invoices: 0,
total_revenue_cents: 0,
draft_count: 0,
issued_count: 0,
paid_count: 0,
cancelled_count: 0
},
// Invoices list
invoices: [],
totalInvoices: 0,
page: 1,
perPage: 20,
filters: {
status: ''
},
// Create invoice modal
showCreateModal: false,
createForm: {
order_id: '',
notes: ''
},
async init() {
// Guard against multiple initialization
if (window._vendorInvoicesInitialized) {
return;
}
window._vendorInvoicesInitialized = true;
// Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadSettings();
await this.loadStats();
await this.loadInvoices();
},
/**
* Load invoice settings
*/
async loadSettings() {
try {
const response = await apiClient.get('/vendor/invoices/settings');
if (response) {
this.settings = response;
this.hasSettings = true;
// Populate form with existing settings
this.settingsForm = {
company_name: response.company_name || '',
company_address: response.company_address || '',
company_city: response.company_city || '',
company_postal_code: response.company_postal_code || '',
company_country: response.company_country || 'LU',
vat_number: response.vat_number || '',
invoice_prefix: response.invoice_prefix || 'INV',
default_vat_rate: response.default_vat_rate?.toString() || '17.00',
bank_name: response.bank_name || '',
bank_iban: response.bank_iban || '',
bank_bic: response.bank_bic || '',
payment_terms: response.payment_terms || 'Net 30 days',
footer_text: response.footer_text || ''
};
} else {
this.hasSettings = false;
}
} catch (error) {
// 404 means not configured yet, which is fine
if (error.status !== 404) {
console.error('[VENDOR INVOICES] Failed to load settings:', error);
}
this.hasSettings = false;
}
},
/**
* Load invoice statistics
*/
async loadStats() {
try {
const response = await apiClient.get('/vendor/invoices/stats');
this.stats = {
total_invoices: response.total_invoices || 0,
total_revenue_cents: response.total_revenue_cents || 0,
draft_count: response.draft_count || 0,
issued_count: response.issued_count || 0,
paid_count: response.paid_count || 0,
cancelled_count: response.cancelled_count || 0
};
} catch (error) {
console.error('[VENDOR INVOICES] Failed to load stats:', error);
}
},
/**
* Load invoices list
*/
async loadInvoices() {
this.loading = true;
this.error = '';
try {
const params = new URLSearchParams({
page: this.page.toString(),
per_page: this.perPage.toString()
});
if (this.filters.status) {
params.append('status', this.filters.status);
}
const response = await apiClient.get(`/vendor/invoices?${params}`);
this.invoices = response.items || [];
this.totalInvoices = response.total || 0;
} catch (error) {
console.error('[VENDOR INVOICES] Failed to load invoices:', error);
this.error = error.message || 'Failed to load invoices';
} finally {
this.loading = false;
}
},
/**
* Refresh all data
*/
async refreshData() {
await this.loadSettings();
await this.loadStats();
await this.loadInvoices();
this.successMessage = 'Data refreshed';
setTimeout(() => this.successMessage = '', 3000);
},
/**
* Save invoice settings
*/
async saveSettings() {
if (!this.settingsForm.company_name) {
this.error = 'Company name is required';
return;
}
this.savingSettings = true;
this.error = '';
try {
const payload = {
company_name: this.settingsForm.company_name,
company_address: this.settingsForm.company_address || null,
company_city: this.settingsForm.company_city || null,
company_postal_code: this.settingsForm.company_postal_code || null,
company_country: this.settingsForm.company_country || 'LU',
vat_number: this.settingsForm.vat_number || null,
invoice_prefix: this.settingsForm.invoice_prefix || 'INV',
default_vat_rate: parseFloat(this.settingsForm.default_vat_rate) || 17.0,
bank_name: this.settingsForm.bank_name || null,
bank_iban: this.settingsForm.bank_iban || null,
bank_bic: this.settingsForm.bank_bic || null,
payment_terms: this.settingsForm.payment_terms || null,
footer_text: this.settingsForm.footer_text || null
};
let response;
if (this.hasSettings) {
// Update existing settings
response = await apiClient.put('/vendor/invoices/settings', payload);
} else {
// Create new settings
response = await apiClient.post('/vendor/invoices/settings', payload);
}
this.settings = response;
this.hasSettings = true;
this.successMessage = 'Settings saved successfully';
} catch (error) {
console.error('[VENDOR INVOICES] Failed to save settings:', error);
this.error = error.message || 'Failed to save settings';
} finally {
this.savingSettings = false;
setTimeout(() => this.successMessage = '', 5000);
}
},
/**
* Open create invoice modal
*/
openCreateModal() {
if (!this.hasSettings) {
this.error = 'Please configure invoice settings first';
this.activeTab = 'settings';
return;
}
this.createForm = {
order_id: '',
notes: ''
};
this.showCreateModal = true;
},
/**
* Create invoice from order
*/
async createInvoice() {
if (!this.createForm.order_id) {
this.error = 'Please enter an order ID';
return;
}
this.creatingInvoice = true;
this.error = '';
try {
const payload = {
order_id: parseInt(this.createForm.order_id),
notes: this.createForm.notes || null
};
const response = await apiClient.post('/vendor/invoices', payload);
this.showCreateModal = false;
this.successMessage = `Invoice ${response.invoice_number} created successfully`;
await this.loadStats();
await this.loadInvoices();
} catch (error) {
console.error('[VENDOR INVOICES] Failed to create invoice:', error);
this.error = error.message || 'Failed to create invoice';
} finally {
this.creatingInvoice = false;
setTimeout(() => this.successMessage = '', 5000);
}
},
/**
* Update invoice status
*/
async updateStatus(invoice, newStatus) {
const statusLabels = {
'issued': 'mark as issued',
'paid': 'mark as paid',
'cancelled': 'cancel'
};
if (!confirm(`Are you sure you want to ${statusLabels[newStatus] || newStatus} this invoice?`)) {
return;
}
try {
await apiClient.put(`/vendor/invoices/${invoice.id}/status`, {
status: newStatus
});
this.successMessage = `Invoice ${invoice.invoice_number} status updated to ${newStatus}`;
await this.loadStats();
await this.loadInvoices();
} catch (error) {
console.error('[VENDOR INVOICES] Failed to update status:', error);
this.error = error.message || 'Failed to update invoice status';
}
setTimeout(() => this.successMessage = '', 5000);
},
/**
* Download invoice PDF
*/
async downloadPDF(invoice) {
this.downloadingPdf = true;
try {
// Get the token for authentication
const token = localStorage.getItem('wizamart_token') || localStorage.getItem('vendor_token');
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/vendor/invoices/${invoice.id}/pdf`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to download PDF');
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `invoice-${invoice.invoice_number}.pdf`;
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
filename = match[1];
}
}
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
this.successMessage = `Downloaded: ${filename}`;
} catch (error) {
console.error('[VENDOR INVOICES] Failed to download PDF:', error);
this.error = error.message || 'Failed to download PDF';
} finally {
this.downloadingPdf = false;
setTimeout(() => this.successMessage = '', 5000);
}
},
/**
* Format date for display
*/
formatDate(dateStr) {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
},
/**
* Format currency for display
*/
formatCurrency(cents, currency = 'EUR') {
if (cents === null || cents === undefined) return 'N/A';
const amount = cents / 100;
return new Intl.NumberFormat('de-LU', {
style: 'currency',
currency: currency
}).format(amount);
}
};
}