mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-04 02:30:01 -06:00
Add admin PDF Layout Editor with local GrapesJS (no CDN) Routes: GET/POST /admin/pdf-layout (save, server-side default seeding) POST /admin/pdf-layout/reset (clear custom template) GET /admin/pdf-layout/default (serve default body HTML/CSS) POST /admin/pdf-layout/preview (render preview with sample context) Invoice PDF generator: support custom HTML/CSS and i18n; add default template and CSS Preview: sanitize Jinja, add helpers (format_date, format_money), sample item Base layout: include head_extra and scripts_extra Editor UI: removed quick blocks, preview, and insert variables; keep load/save/reset Vendor GrapesJS under app/static/vendor/grapesjs and load locally README: document the new feature and usage
594 lines
21 KiB
Python
594 lines
21 KiB
Python
"""
|
|
PDF Generation utility for invoices
|
|
Uses WeasyPrint to generate professional PDF invoices
|
|
"""
|
|
|
|
import os
|
|
import html as html_lib
|
|
from datetime import datetime
|
|
from weasyprint import HTML, CSS
|
|
from weasyprint.text.fonts import FontConfiguration
|
|
from app.models import Settings
|
|
from flask import current_app
|
|
from flask_babel import gettext as _
|
|
try:
|
|
from babel.dates import format_date as babel_format_date
|
|
except Exception:
|
|
babel_format_date = None
|
|
from pathlib import Path
|
|
from flask import render_template
|
|
|
|
class InvoicePDFGenerator:
|
|
"""Generate PDF invoices with company branding"""
|
|
|
|
def __init__(self, invoice, settings=None):
|
|
self.invoice = invoice
|
|
self.settings = settings or Settings.get_settings()
|
|
|
|
def generate_pdf(self):
|
|
"""Generate PDF content and return as bytes"""
|
|
use_custom_html = bool((self.settings.invoice_pdf_template_html or '').strip())
|
|
use_custom_css = bool((self.settings.invoice_pdf_template_css or '').strip())
|
|
if use_custom_html or use_custom_css:
|
|
html_content, css_content = self._render_from_custom_template()
|
|
else:
|
|
html_content = self._generate_html()
|
|
css_content = self._generate_css()
|
|
|
|
# Configure fonts
|
|
font_config = FontConfiguration()
|
|
|
|
# Create PDF (avoid passing unexpected args to PDF class)
|
|
base_url = None
|
|
try:
|
|
base_url = current_app.root_path
|
|
except Exception:
|
|
base_url = None
|
|
html_doc = HTML(string=html_content, base_url=base_url)
|
|
css_doc = CSS(string=css_content, font_config=font_config)
|
|
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config)
|
|
|
|
return pdf_bytes
|
|
def _render_from_custom_template(self):
|
|
"""Render HTML and CSS from custom templates stored in settings, with fallback to default template."""
|
|
html_template = (self.settings.invoice_pdf_template_html or '').strip()
|
|
css_template = (self.settings.invoice_pdf_template_css or '').strip()
|
|
html = ''
|
|
if css_template:
|
|
css = css_template
|
|
else:
|
|
try:
|
|
from flask import render_template as _render_tpl
|
|
css = _render_tpl('invoices/pdf_styles_default.css')
|
|
except Exception:
|
|
css = self._generate_css()
|
|
try:
|
|
# Render using Flask's Jinja environment to include app filters and _()
|
|
if html_template:
|
|
from flask import render_template_string
|
|
html = render_template_string(html_template, invoice=self.invoice, settings=self.settings, Path=Path)
|
|
except Exception:
|
|
html = ''
|
|
if not html:
|
|
try:
|
|
html = render_template('invoices/pdf_default.html', invoice=self.invoice, settings=self.settings, Path=Path)
|
|
except Exception:
|
|
html = f"<html><body><h1>{_('Invoice')} {self.invoice.invoice_number}</h1></body></html>"
|
|
return html, css
|
|
|
|
def _generate_html(self):
|
|
"""Generate HTML content for the invoice"""
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>{_('Invoice')} {self.invoice.invoice_number}</title>
|
|
<style>
|
|
:root {{
|
|
--primary: #2563eb;
|
|
--primary-600: #1d4ed8;
|
|
--text: #0f172a;
|
|
--muted: #475569;
|
|
--border: #e2e8f0;
|
|
--bg: #ffffff;
|
|
--bg-alt: #f8fafc;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
color: var(--text);
|
|
margin: 0;
|
|
padding: 0;
|
|
background: var(--bg);
|
|
font-size: 12pt;
|
|
}}
|
|
.wrapper {{
|
|
padding: 24px 28px;
|
|
}}
|
|
.invoice-header {{
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
border-bottom: 2px solid var(--border);
|
|
padding-bottom: 16px;
|
|
margin-bottom: 18px;
|
|
}}
|
|
.brand {{ display: flex; gap: 16px; align-items: center; }}
|
|
.company-logo {{ max-width: 140px; max-height: 70px; display: block; }}
|
|
.company-name {{ font-size: 22pt; font-weight: 700; margin: 0; color: var(--primary); }}
|
|
.company-meta span {{ display: block; color: var(--muted); font-size: 10pt; }}
|
|
.invoice-meta {{ text-align: right; }}
|
|
.invoice-title {{ font-size: 26pt; font-weight: 800; color: var(--primary); margin: 0 0 8px 0; }}
|
|
.meta-grid {{ display: grid; grid-template-columns: auto auto; gap: 4px 16px; font-size: 10.5pt; }}
|
|
.label {{ color: var(--muted); font-weight: 600; }}
|
|
.value {{ color: var(--text); font-weight: 600; }}
|
|
|
|
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 18px; }}
|
|
.card {{ background: var(--bg-alt); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }}
|
|
.section-title {{ font-size: 12pt; font-weight: 700; color: var(--primary-600); margin: 0 0 8px 0; }}
|
|
.small {{ color: var(--muted); font-size: 10pt; }}
|
|
|
|
table {{ width: 100%; border-collapse: collapse; margin-top: 4px; }}
|
|
thead {{ display: table-header-group; }}
|
|
tfoot {{ display: table-footer-group; }}
|
|
thead th {{ background: var(--bg-alt); color: var(--muted); font-weight: 700; border: 1px solid var(--border); padding: 10px; font-size: 10.5pt; text-align: left; }}
|
|
tbody td {{ border: 1px solid var(--border); padding: 10px; font-size: 10.5pt; }}
|
|
tfoot td {{ border: 1px solid var(--border); padding: 10px; font-weight: 700; }}
|
|
.num {{ text-align: right; }}
|
|
.desc {{ width: 50%; }}
|
|
|
|
/* Pagination controls */
|
|
tr, td, th {{ break-inside: avoid; page-break-inside: avoid; }}
|
|
.card, .invoice-header, .two-col {{ break-inside: avoid; page-break-inside: avoid; }}
|
|
h4 {{ break-after: avoid; }}
|
|
|
|
.totals {{ margin-top: 6px; }}
|
|
.note {{ margin-top: 10px; }}
|
|
.footer {{ border-top: 1px solid var(--border); margin-top: 18px; padding-top: 10px; color: var(--muted); font-size: 10pt; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrapper">
|
|
<!-- Header -->
|
|
<div class="invoice-header">
|
|
<div class="brand">
|
|
{self._get_company_logo_html()}
|
|
<div>
|
|
<h1 class="company-name">{self._escape(self.settings.company_name)}</h1>
|
|
<div class="company-meta small">
|
|
<span>{self._nl2br(self.settings.company_address)}</span>
|
|
<span>{_('Email')}: {self._escape(self.settings.company_email)} · {_('Phone')}: {self._escape(self.settings.company_phone)}</span>
|
|
<span>{_('Website')}: {self._escape(self.settings.company_website)}</span>
|
|
{self._get_company_tax_info()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="invoice-meta">
|
|
<div class="invoice-title">{_('INVOICE')}</div>
|
|
<div class="meta-grid">
|
|
<div class="label">{_('Invoice #')}</div><div class="value">{self.invoice.invoice_number}</div>
|
|
<div class="label">{_('Issue Date')}</div><div class="value">{(babel_format_date(self.invoice.issue_date) if babel_format_date else self.invoice.issue_date.strftime('%Y-%m-%d'))}</div>
|
|
<div class="label">{_('Due Date')}</div><div class="value">{(babel_format_date(self.invoice.due_date) if babel_format_date else self.invoice.due_date.strftime('%Y-%m-%d'))}</div>
|
|
<div class="label">{_('Status')}</div><div class="value">{_(self.invoice.status.title())}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Client Information -->
|
|
<div class="two-col">
|
|
<div class="card">
|
|
<div class="section-title">{_('Bill To')}</div>
|
|
<div><strong>{self._escape(self.invoice.client_name)}</strong></div>
|
|
{self._get_client_email_html()}
|
|
{self._get_client_address_html()}
|
|
</div>
|
|
<div class="card">
|
|
<div class="section-title">{_('Project')}</div>
|
|
<div><strong>{self._escape(self.invoice.project.name)}</strong></div>
|
|
{self._get_project_description_html()}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Invoice Items -->
|
|
<div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="desc">{_('Description')}</th>
|
|
<th class="num">{_('Quantity (Hours)')}</th>
|
|
<th class="num">{_('Unit Price')}</th>
|
|
<th class="num">{_('Total Amount')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{self._generate_items_rows()}
|
|
</tbody>
|
|
<tfoot>
|
|
{self._generate_totals_rows()}
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Additional Information -->
|
|
{self._get_additional_info_html()}
|
|
|
|
<!-- Footer -->
|
|
<div class="footer">
|
|
{self._get_payment_info_html()}
|
|
<div><strong>{_('Terms & Conditions:')}</strong> {self._escape(self.settings.invoice_terms)}</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return html
|
|
|
|
def _escape(self, value):
|
|
return html_lib.escape(value) if value else ''
|
|
|
|
def _nl2br(self, value):
|
|
if not value:
|
|
return ''
|
|
return self._escape(value).replace('\n', '<br>')
|
|
|
|
def _get_company_logo_html(self):
|
|
"""Generate HTML for company logo if available"""
|
|
if self.settings.has_logo():
|
|
logo_path = self.settings.get_logo_path()
|
|
if logo_path and os.path.exists(logo_path):
|
|
# Build a cross-platform file URI (handles Windows and POSIX paths)
|
|
try:
|
|
file_url = Path(logo_path).resolve().as_uri()
|
|
except Exception:
|
|
# Fallback to naive file:// if as_uri fails
|
|
file_url = f'file://{logo_path}'
|
|
return f'<img src="{file_url}" alt="Company Logo" class="company-logo">'
|
|
return ''
|
|
|
|
def _get_company_tax_info(self):
|
|
"""Generate HTML for company tax information"""
|
|
if self.settings.company_tax_id:
|
|
return f'<div class="company-tax">Tax ID: {self.settings.company_tax_id}</div>'
|
|
return ''
|
|
|
|
def _get_client_email_html(self):
|
|
"""Generate HTML for client email if available"""
|
|
if self.invoice.client_email:
|
|
return f'<div class="client-email">{self.invoice.client_email}</div>'
|
|
return ''
|
|
|
|
def _get_client_address_html(self):
|
|
"""Generate HTML for client address if available"""
|
|
if self.invoice.client_address:
|
|
return f'<div class="client-address">{self.invoice.client_address}</div>'
|
|
return ''
|
|
|
|
def _get_project_description_html(self):
|
|
"""Generate HTML for project description if available"""
|
|
if self.invoice.project.description:
|
|
return f'<div class="project-description">{self.invoice.project.description}</div>'
|
|
return ''
|
|
|
|
def _generate_items_rows(self):
|
|
"""Generate HTML rows for invoice items"""
|
|
rows = []
|
|
for item in self.invoice.items:
|
|
row = f"""
|
|
<tr>
|
|
<td>
|
|
{self._escape(item.description)}
|
|
{self._get_time_entry_info_html(item)}
|
|
</td>
|
|
<td class="num">{item.quantity:.2f}</td>
|
|
<td class="num">{self._format_currency(item.unit_price)}</td>
|
|
<td class="num">{self._format_currency(item.total_amount)}</td>
|
|
</tr>
|
|
"""
|
|
rows.append(row)
|
|
return ''.join(rows)
|
|
|
|
def _get_time_entry_info_html(self, item):
|
|
"""Generate HTML for time entry information if available"""
|
|
if item.time_entry_ids:
|
|
count = len(item.time_entry_ids.split(','))
|
|
return f'<br><small class="time-entry-info">Generated from {count} time entries</small>'
|
|
return ''
|
|
|
|
def _generate_totals_rows(self):
|
|
"""Generate HTML rows for invoice totals"""
|
|
rows = []
|
|
|
|
# Subtotal
|
|
rows.append(f"""
|
|
<tr>
|
|
<td colspan="3" class="num">Subtotal:</td>
|
|
<td class="num">{self._format_currency(self.invoice.subtotal)}</td>
|
|
</tr>
|
|
""")
|
|
|
|
# Tax if applicable
|
|
if self.invoice.tax_rate > 0:
|
|
rows.append(f"""
|
|
<tr>
|
|
<td colspan="3" class="num">Tax ({self.invoice.tax_rate:.2f}%):</td>
|
|
<td class="num">{self._format_currency(self.invoice.tax_amount)}</td>
|
|
</tr>
|
|
""")
|
|
|
|
# Total
|
|
rows.append(f"""
|
|
<tr>
|
|
<td colspan="3" class="num">Total Amount:</td>
|
|
<td class="num">{self._format_currency(self.invoice.total_amount)}</td>
|
|
</tr>
|
|
""")
|
|
|
|
return ''.join(rows)
|
|
|
|
def _get_additional_info_html(self):
|
|
"""Generate HTML for additional invoice information"""
|
|
html_parts = []
|
|
|
|
if self.invoice.notes:
|
|
html_parts.append(f"""
|
|
<div class="notes-section">
|
|
<h4>{_('Notes:')}</h4>
|
|
<p>{self.invoice.notes}</p>
|
|
</div>
|
|
""")
|
|
|
|
if self.invoice.terms:
|
|
html_parts.append(f"""
|
|
<div class="terms-section">
|
|
<h4>{_('Terms:')}</h4>
|
|
<p>{self.invoice.terms}</p>
|
|
</div>
|
|
""")
|
|
|
|
if html_parts:
|
|
return f'<div class="additional-info">{"".join(html_parts)}</div>'
|
|
return ''
|
|
|
|
def _format_currency(self, value):
|
|
"""Format numeric currency with thousands separators and 2 decimals."""
|
|
try:
|
|
return f"{float(value):,.2f} {self.settings.currency}"
|
|
except Exception:
|
|
return f"{value} {self.settings.currency}"
|
|
|
|
def _get_payment_info_html(self):
|
|
"""Generate HTML for payment information"""
|
|
if self.settings.company_bank_info:
|
|
return f"""
|
|
<h4>{_('Payment Information:')}</h4>
|
|
<div class="bank-info">{self.settings.company_bank_info}</div>
|
|
"""
|
|
return ''
|
|
|
|
def _generate_css(self):
|
|
"""Generate CSS styles for the invoice"""
|
|
return """
|
|
@page {
|
|
size: A4;
|
|
margin: 2cm;
|
|
@bottom-center {
|
|
content: "Page " counter(page) " of " counter(pages);
|
|
font-size: 10pt;
|
|
color: #666;
|
|
}
|
|
}
|
|
|
|
body {
|
|
font-family: 'Helvetica Neue', Arial, sans-serif;
|
|
font-size: 12pt;
|
|
line-height: 1.4;
|
|
color: #333;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.invoice-container {
|
|
max-width: 100%;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 2em;
|
|
border-bottom: 2px solid #007bff;
|
|
padding-bottom: 1em;
|
|
}
|
|
|
|
.company-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.company-logo {
|
|
max-width: 150px;
|
|
max-height: 80px;
|
|
display: block;
|
|
margin-left: auto;
|
|
margin-right: 0;
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.company-name {
|
|
font-size: 24pt;
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
margin: 0 0 0.5em 0;
|
|
}
|
|
|
|
.company-address {
|
|
margin-bottom: 0.5em;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.company-contact {
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.company-contact span {
|
|
display: block;
|
|
margin-bottom: 0.2em;
|
|
font-size: 10pt;
|
|
}
|
|
|
|
.company-tax {
|
|
font-size: 10pt;
|
|
color: #666;
|
|
}
|
|
|
|
.invoice-info {
|
|
text-align: right;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.logo-container {
|
|
text-align: right;
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.invoice-title {
|
|
font-size: 28pt;
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
margin: 0 0 1em 0;
|
|
}
|
|
|
|
.invoice-details .detail-row {
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.detail-row .label {
|
|
font-weight: bold;
|
|
margin-right: 0.5em;
|
|
}
|
|
|
|
.status-draft { color: #6c757d; }
|
|
.status-sent { color: #17a2b8; }
|
|
.status-paid { color: #28a745; }
|
|
.status-overdue { color: #dc3545; }
|
|
.status-cancelled { color: #343a40; }
|
|
|
|
.client-section, .project-section {
|
|
margin-bottom: 2em;
|
|
}
|
|
|
|
.client-section h3, .project-section h3 {
|
|
font-size: 14pt;
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
margin: 0 0 0.5em 0;
|
|
border-bottom: 1px solid #dee2e6;
|
|
padding-bottom: 0.3em;
|
|
}
|
|
|
|
.client-name {
|
|
font-weight: bold;
|
|
font-size: 14pt;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.client-email, .client-address, .project-description {
|
|
margin-bottom: 0.3em;
|
|
color: #666;
|
|
}
|
|
|
|
.items-section {
|
|
margin-bottom: 2em;
|
|
}
|
|
|
|
.invoice-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.invoice-table th,
|
|
.invoice-table td {
|
|
border: 1px solid #dee2e6;
|
|
padding: 0.75em;
|
|
text-align: left;
|
|
}
|
|
|
|
.invoice-table th {
|
|
background-color: #f8f9fa;
|
|
font-weight: bold;
|
|
color: #495057;
|
|
}
|
|
|
|
.description { width: 40%; }
|
|
.quantity { width: 15%; text-align: center; }
|
|
.unit-price { width: 20%; text-align: right; }
|
|
.total { width: 25%; text-align: right; }
|
|
|
|
.text-center { text-align: center; }
|
|
.text-right { text-align: right; }
|
|
|
|
.time-entry-info {
|
|
color: #6c757d;
|
|
font-style: italic;
|
|
}
|
|
|
|
.subtotal { background-color: #f8f9fa; }
|
|
.tax { background-color: #fff3cd; }
|
|
.total { background-color: #d1ecf1; font-weight: bold; }
|
|
|
|
.additional-info {
|
|
margin-bottom: 2em;
|
|
}
|
|
|
|
.notes-section, .terms-section {
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.notes-section h4, .terms-section h4 {
|
|
font-size: 12pt;
|
|
font-weight: bold;
|
|
color: #495057;
|
|
margin: 0 0 0.5em 0;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: 2em;
|
|
padding-top: 1em;
|
|
border-top: 1px solid #dee2e6;
|
|
}
|
|
|
|
.payment-info {
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.payment-info h4 {
|
|
font-size: 12pt;
|
|
font-weight: bold;
|
|
color: #495057;
|
|
margin: 0 0 0.5em 0;
|
|
}
|
|
|
|
.bank-info {
|
|
color: #666;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.terms h4 {
|
|
font-size: 12pt;
|
|
font-weight: bold;
|
|
color: #495057;
|
|
margin: 0 0 0.5em 0;
|
|
}
|
|
|
|
.terms p {
|
|
color: #666;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
/* Utility classes */
|
|
.nl2br {
|
|
white-space: pre-line;
|
|
}
|
|
"""
|