mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-09 05:50:06 -05:00
PDF Layout Editor: local GrapesJS, admin UI, i18n, preview fixes
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
This commit is contained in:
@@ -398,6 +398,23 @@ See [Version Management Documentation](docs/VERSION_MANAGEMENT.md) for detailed
|
||||
- **CLI & Admin Tools**: Database migrations, management scripts, and utilities
|
||||
- **Dockerized Deployment**: Local and remote compose files, public images on GitHub Container Registry
|
||||
|
||||
### New: PDF Layout Editor (Admin)
|
||||
- **Visual editor** to customize the invoice PDF layout with HTML and CSS
|
||||
- **Local assets**: GrapesJS is served from `static/vendor` (no CDN dependency)
|
||||
- **Live preview** in the editor to validate changes before export
|
||||
- **Translations**: Fully supports Flask‑Babel strings like `{{ _('Invoice') }}`
|
||||
- **Company branding**: Uses values from Admin → System Settings (logo, address, etc.)
|
||||
- **Safe defaults**: One‑click “Load Defaults” provides a complete starter template
|
||||
|
||||
Usage:
|
||||
1. Open `Admin → System Settings` and click “Edit PDF Layout”.
|
||||
2. Adjust HTML and CSS. Use `{{ format_date(...) }}`, `{{ format_money(...) }}`, `{{ _('...') }}`.
|
||||
3. Click “Save Layout”. Export any invoice to see the new design.
|
||||
|
||||
Notes:
|
||||
- The editor stores custom template HTML/CSS in the `settings` table; leave blank to use built‑in defaults.
|
||||
- Preview sanitizes pasted content to avoid smart quotes and HTML entities breaking Jinja.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
Detailed documentation is available in the `docs/` directory:
|
||||
|
||||
@@ -29,6 +29,10 @@ class Settings(db.Model):
|
||||
company_tax_id = db.Column(db.String(100), default='', nullable=True)
|
||||
company_bank_info = db.Column(db.Text, default='', nullable=True)
|
||||
|
||||
# PDF template customization
|
||||
invoice_pdf_template_html = db.Column(db.Text, default='', nullable=True)
|
||||
invoice_pdf_template_css = db.Column(db.Text, default='', nullable=True)
|
||||
|
||||
# Invoice defaults
|
||||
invoice_prefix = db.Column(db.String(10), default='INV', nullable=False)
|
||||
invoice_start_number = db.Column(db.Integer, default=1000, nullable=False)
|
||||
@@ -63,6 +67,10 @@ class Settings(db.Model):
|
||||
self.company_tax_id = kwargs.get('company_tax_id', '')
|
||||
self.company_bank_info = kwargs.get('company_bank_info', '')
|
||||
|
||||
# PDF template customization
|
||||
self.invoice_pdf_template_html = kwargs.get('invoice_pdf_template_html', '')
|
||||
self.invoice_pdf_template_css = kwargs.get('invoice_pdf_template_css', '')
|
||||
|
||||
# Set invoice defaults
|
||||
self.invoice_prefix = kwargs.get('invoice_prefix', 'INV')
|
||||
self.invoice_start_number = kwargs.get('invoice_start_number', 1000)
|
||||
@@ -127,6 +135,8 @@ class Settings(db.Model):
|
||||
'invoice_start_number': self.invoice_start_number,
|
||||
'invoice_terms': self.invoice_terms,
|
||||
'invoice_notes': self.invoice_notes,
|
||||
'invoice_pdf_template_html': self.invoice_pdf_template_html,
|
||||
'invoice_pdf_template_css': self.invoice_pdf_template_css,
|
||||
'allow_analytics': self.allow_analytics,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
|
||||
+222
-2
@@ -1,8 +1,8 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file, jsonify, render_template_string
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import User, Project, TimeEntry, Settings
|
||||
from app.models import User, Project, TimeEntry, Settings, Invoice
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
import os
|
||||
@@ -258,6 +258,226 @@ def settings():
|
||||
|
||||
return render_template('admin/settings.html', settings=settings_obj)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout():
|
||||
"""Edit PDF invoice layout template (HTML and CSS)."""
|
||||
settings_obj = Settings.get_settings()
|
||||
if request.method == 'POST':
|
||||
html_template = request.form.get('invoice_pdf_template_html', '')
|
||||
css_template = request.form.get('invoice_pdf_template_css', '')
|
||||
settings_obj.invoice_pdf_template_html = html_template
|
||||
settings_obj.invoice_pdf_template_css = css_template
|
||||
if not safe_commit('admin_update_pdf_layout'):
|
||||
from flask_babel import gettext as _
|
||||
flash(_('Could not update PDF layout due to a database error.'), 'error')
|
||||
else:
|
||||
from flask_babel import gettext as _
|
||||
flash(_('PDF layout updated successfully'), 'success')
|
||||
return redirect(url_for('admin.pdf_layout'))
|
||||
# Provide initial defaults to the template if no custom HTML/CSS saved
|
||||
initial_html = settings_obj.invoice_pdf_template_html or ''
|
||||
initial_css = settings_obj.invoice_pdf_template_css or ''
|
||||
try:
|
||||
if not initial_html:
|
||||
env = current_app.jinja_env
|
||||
html_src, _, _ = env.loader.get_source(env, 'invoices/pdf_default.html')
|
||||
# Extract body only for editor
|
||||
try:
|
||||
import re as _re
|
||||
m = _re.search(r'<body[^>]*>([\s\S]*?)</body>', html_src, _re.IGNORECASE)
|
||||
initial_html = (m.group(1).strip() if m else html_src)
|
||||
except Exception:
|
||||
pass
|
||||
if not initial_css:
|
||||
env = current_app.jinja_env
|
||||
css_src, _, _ = env.loader.get_source(env, 'invoices/pdf_styles_default.css')
|
||||
initial_css = css_src
|
||||
except Exception:
|
||||
pass
|
||||
return render_template('admin/pdf_layout.html', settings=settings_obj, initial_html=initial_html, initial_css=initial_css)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout/reset', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout_reset():
|
||||
"""Reset PDF layout to defaults (clear custom templates)."""
|
||||
settings_obj = Settings.get_settings()
|
||||
settings_obj.invoice_pdf_template_html = ''
|
||||
settings_obj.invoice_pdf_template_css = ''
|
||||
if not safe_commit('admin_reset_pdf_layout'):
|
||||
flash(_('Could not reset PDF layout due to a database error.'), 'error')
|
||||
else:
|
||||
flash(_('PDF layout reset to defaults'), 'success')
|
||||
return redirect(url_for('admin.pdf_layout'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout/default', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout_default():
|
||||
"""Return default HTML and CSS template sources for the PDF layout editor."""
|
||||
try:
|
||||
env = current_app.jinja_env
|
||||
# Get raw template sources, not rendered
|
||||
html_src, _, _ = env.loader.get_source(env, 'invoices/pdf_default.html')
|
||||
# Extract only the body content for GrapesJS
|
||||
try:
|
||||
import re as _re
|
||||
match = _re.search(r'<body[^>]*>([\s\S]*?)</body>', html_src, _re.IGNORECASE)
|
||||
if match:
|
||||
html_src = match.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
html_src = '<div class="wrapper"><h1>{{ _(\'INVOICE\') }} {{ invoice.invoice_number }}</h1></div>'
|
||||
try:
|
||||
css_src, _, _ = env.loader.get_source(env, 'invoices/pdf_styles_default.css')
|
||||
except Exception:
|
||||
css_src = ''
|
||||
return jsonify({
|
||||
'html': html_src,
|
||||
'css': css_src,
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout/preview', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout_preview():
|
||||
"""Render a live preview of the provided HTML/CSS using an invoice context."""
|
||||
html = request.form.get('html', '')
|
||||
css = request.form.get('css', '')
|
||||
invoice_id = request.form.get('invoice_id', type=int)
|
||||
invoice = None
|
||||
if invoice_id:
|
||||
invoice = Invoice.query.get(invoice_id)
|
||||
if invoice is None:
|
||||
invoice = Invoice.query.order_by(Invoice.id.desc()).first()
|
||||
settings_obj = Settings.get_settings()
|
||||
|
||||
# Provide a minimal mock invoice if none exists to avoid template errors
|
||||
from types import SimpleNamespace
|
||||
if invoice is None:
|
||||
from datetime import date
|
||||
invoice = SimpleNamespace(
|
||||
invoice_number='0000',
|
||||
issue_date=date.today(),
|
||||
due_date=date.today(),
|
||||
status='draft',
|
||||
client_name='Sample Client',
|
||||
client_email='',
|
||||
client_address='',
|
||||
project=SimpleNamespace(name='Sample Project', description=''),
|
||||
items=[],
|
||||
subtotal=0.0,
|
||||
tax_rate=0.0,
|
||||
tax_amount=0.0,
|
||||
total_amount=0.0,
|
||||
notes='',
|
||||
terms='',
|
||||
)
|
||||
# Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops
|
||||
sample_item = SimpleNamespace(description='Sample item', quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids='')
|
||||
try:
|
||||
if not getattr(invoice, 'items', None):
|
||||
invoice.items = [sample_item]
|
||||
except Exception:
|
||||
try:
|
||||
invoice.items = [sample_item]
|
||||
except Exception:
|
||||
pass
|
||||
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
|
||||
def _sanitize_jinja_blocks(raw: str) -> str:
|
||||
try:
|
||||
import re as _re
|
||||
import html as _html
|
||||
smart_map = {
|
||||
'\u201c': '"', '\u201d': '"', # “ ” -> "
|
||||
'\u2018': "'", '\u2019': "'", # ‘ ’ -> '
|
||||
'\u00a0': ' ', # nbsp
|
||||
'\u200b': '', '\u200c': '', '\u200d': '', # zero-width
|
||||
}
|
||||
def _fix_quotes(s: str) -> str:
|
||||
for k, v in smart_map.items():
|
||||
s = s.replace(k, v)
|
||||
return s
|
||||
def _clean(match):
|
||||
open_tag = match.group(1)
|
||||
inner = match.group(2)
|
||||
# Remove any HTML tags GrapesJS may have inserted inside Jinja braces
|
||||
inner = _re.sub(r'</?[^>]+?>', '', inner)
|
||||
# Decode HTML entities
|
||||
inner = _html.unescape(inner)
|
||||
# Fix smart quotes and nbsp
|
||||
inner = _fix_quotes(inner)
|
||||
# Trim excessive whitespace around pipes and parentheses
|
||||
inner = _re.sub(r'\s+\|\s+', ' | ', inner)
|
||||
inner = _re.sub(r'\(\s+', '(', inner)
|
||||
inner = _re.sub(r'\s+\)', ')', inner)
|
||||
# Normalize _("...") -> _('...')
|
||||
inner = inner.replace('_("', "_('").replace('")', "')")
|
||||
return f"{open_tag}{inner}{' }}' if open_tag == '{{ ' else ' %}'}"
|
||||
pattern = _re.compile(r'({{\s|{%\s)([\s\S]*?)(?:}}|%})')
|
||||
return _re.sub(pattern, _clean, raw)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
sanitized = _sanitize_jinja_blocks(html)
|
||||
|
||||
# Wrap provided HTML with a minimal page and CSS
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
# Provide helpers as callables since templates may use function-style helpers
|
||||
try:
|
||||
from babel.dates import format_date as _babel_format_date
|
||||
except Exception:
|
||||
_babel_format_date = None
|
||||
def _format_date(value, format='medium'):
|
||||
try:
|
||||
if _babel_format_date:
|
||||
if format == 'full':
|
||||
return _babel_format_date(value, format='full')
|
||||
if format == 'long':
|
||||
return _babel_format_date(value, format='long')
|
||||
if format == 'short':
|
||||
return _babel_format_date(value, format='short')
|
||||
return _babel_format_date(value, format='medium')
|
||||
return value.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
return str(value)
|
||||
def _format_money(value):
|
||||
try:
|
||||
return f"{float(value):,.2f} {settings_obj.currency}"
|
||||
except Exception:
|
||||
return f"{value} {settings_obj.currency}"
|
||||
body_html = render_template_string(
|
||||
sanitized,
|
||||
invoice=invoice,
|
||||
settings=settings_obj,
|
||||
Path=_Path,
|
||||
format_date=_format_date,
|
||||
format_money=_format_money,
|
||||
item=sample_item,
|
||||
)
|
||||
except Exception as e:
|
||||
body_html = f"<div style='color:red'>Template error: {str(e)}</div>" + sanitized
|
||||
page_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<title>PDF Preview</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>{body_html}</body>
|
||||
</html>
|
||||
"""
|
||||
return page_html
|
||||
|
||||
@admin_bp.route('/admin/upload-logo', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
+3
-17
@@ -391,51 +391,37 @@ def export_invoice_csv(invoice_id):
|
||||
def export_invoice_pdf(invoice_id):
|
||||
"""Export invoice as PDF"""
|
||||
invoice = Invoice.query.get_or_404(invoice_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and invoice.created_by != current_user.id:
|
||||
flash('You do not have permission to export this invoice', 'error')
|
||||
flash(_('You do not have permission to export this invoice'), 'error')
|
||||
return redirect(request.referrer or url_for('invoices.list_invoices'))
|
||||
|
||||
try:
|
||||
from app.utils.pdf_generator import InvoicePDFGenerator
|
||||
settings = Settings.get_settings()
|
||||
|
||||
# Generate PDF (primary: WeasyPrint)
|
||||
pdf_generator = InvoicePDFGenerator(invoice, settings=settings)
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
|
||||
filename = f'invoice_{invoice.invoice_number}.pdf'
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(pdf_bytes),
|
||||
mimetype='application/pdf',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Any failure (including ImportError) -> try ReportLab fallback
|
||||
try:
|
||||
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
|
||||
settings = Settings.get_settings()
|
||||
|
||||
flash('High-quality generator unavailable; using fallback PDF generator.', 'warning')
|
||||
|
||||
flash(_('High-quality generator unavailable; using fallback PDF generator.'), 'warning')
|
||||
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
|
||||
filename = f'invoice_{invoice.invoice_number}.pdf'
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(pdf_bytes),
|
||||
mimetype='application/pdf',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as fallback_error:
|
||||
flash(f'PDF generation failed: {str(e)}. Fallback also failed: {str(fallback_error)}', 'error')
|
||||
flash(_('PDF generation failed: %(err)s. Fallback also failed: %(fb)s', err=str(e), fb=str(fallback_error)), 'error')
|
||||
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/duplicate')
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
+3
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}?v={{ app_version }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
{% block head_extra %}{% endblock %}
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
@@ -373,6 +374,7 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% block scripts_extra %}{% endblock %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
<script>
|
||||
// Compact density toggle
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ _('Invoice') }} {{ invoice.invoice_number }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="invoice-header">
|
||||
<div class="brand">
|
||||
{% if settings.has_logo() %}
|
||||
{% set logo_path = settings.get_logo_path() %}
|
||||
{% if logo_path %}
|
||||
<img src="{{ Path(logo_path).resolve().as_uri() if Path and logo_path else 'file://' ~ logo_path }}" alt="{{ _('Company Logo') }}" class="company-logo">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1 class="company-name">{{ settings.company_name|e }}</h1>
|
||||
<div class="company-meta small">
|
||||
<span>{{ settings.company_address|e|replace('\n', '<br>')|safe }}</span>
|
||||
<span>{{ _('Email') }}: {{ settings.company_email|e }} · {{ _('Phone') }}: {{ settings.company_phone|e }}</span>
|
||||
<span>{{ _('Website') }}: {{ settings.company_website|e }}</span>
|
||||
{% if settings.company_tax_id %}
|
||||
<div class="company-tax">{{ _('Tax ID') }}: {{ settings.company_tax_id|e }}</div>
|
||||
{% endif %}
|
||||
</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">{{ invoice.invoice_number }}</div>
|
||||
<div class="label">{{ _('Issue Date') }}</div><div class="value">{{ format_date(invoice.issue_date) }}</div>
|
||||
<div class="label">{{ _('Due Date') }}</div><div class="value">{{ format_date(invoice.due_date) }}</div>
|
||||
<div class="label">{{ _('Status') }}</div><div class="value">{{ _(invoice.status|title) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="card">
|
||||
<div class="section-title">{{ _('Bill To') }}</div>
|
||||
<div><strong>{{ invoice.client_name|e }}</strong></div>
|
||||
{% if invoice.client_email %}
|
||||
<div class="client-email">{{ invoice.client_email|e }}</div>
|
||||
{% endif %}
|
||||
{% if invoice.client_address %}
|
||||
<div class="client-address">{{ invoice.client_address|e }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-title">{{ _('Project') }}</div>
|
||||
<div><strong>{{ invoice.project.name|e }}</strong></div>
|
||||
{% if invoice.project.description %}
|
||||
<div class="project-description">{{ invoice.project.description|e }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% for item in invoice.items %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ item.description|e }}
|
||||
{% if item.time_entry_ids %}
|
||||
{% set count = item.time_entry_ids.split(',')|length %}
|
||||
<br><small class="time-entry-info">{{ _('Generated from %(num)d time entries', num=count) }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ '%.2f' % item.quantity }}</td>
|
||||
<td class="num">{{ format_money(item.unit_price) }}</td>
|
||||
<td class="num">{{ format_money(item.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="num">{{ _('Subtotal:') }}</td>
|
||||
<td class="num">{{ format_money(invoice.subtotal) }}</td>
|
||||
</tr>
|
||||
{% if invoice.tax_rate > 0 %}
|
||||
<tr>
|
||||
<td colspan="3" class="num">{{ _('Tax (%(rate).2f%%):', rate=invoice.tax_rate) }}</td>
|
||||
<td class="num">{{ format_money(invoice.tax_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="3" class="num">{{ _('Total Amount:') }}</td>
|
||||
<td class="num">{{ format_money(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if invoice.notes or invoice.terms %}
|
||||
<div class="additional-info">
|
||||
{% if invoice.notes %}
|
||||
<div class="notes-section">
|
||||
<h4>{{ _('Notes:') }}</h4>
|
||||
<p>{{ invoice.notes|e }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if invoice.terms %}
|
||||
<div class="terms-section">
|
||||
<h4>{{ _('Terms:') }}</h4>
|
||||
<p>{{ invoice.terms|e }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
{% if settings.company_bank_info %}
|
||||
<h4>{{ _('Payment Information:') }}</h4>
|
||||
<div class="bank-info">{{ settings.company_bank_info|e|replace('\n', '<br>')|safe }}</div>
|
||||
{% endif %}
|
||||
<div><strong>{{ _('Terms & Conditions:') }}</strong> {{ settings.invoice_terms|e }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+57
-20
@@ -10,7 +10,13 @@ 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"""
|
||||
@@ -21,8 +27,13 @@ class InvoicePDFGenerator:
|
||||
|
||||
def generate_pdf(self):
|
||||
"""Generate PDF content and return as bytes"""
|
||||
html_content = self._generate_html()
|
||||
css_content = self._generate_css()
|
||||
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()
|
||||
@@ -38,6 +49,32 @@ class InvoicePDFGenerator:
|
||||
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"""
|
||||
@@ -46,7 +83,7 @@ class InvoicePDFGenerator:
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Invoice {self.invoice.invoice_number}</title>
|
||||
<title>{_('Invoice')} {self.invoice.invoice_number}</title>
|
||||
<style>
|
||||
:root {{
|
||||
--primary: #2563eb;
|
||||
@@ -121,19 +158,19 @@ class InvoicePDFGenerator:
|
||||
<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>
|
||||
<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="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">{self.invoice.issue_date.strftime('%b %d, %Y')}</div>
|
||||
<div class="label">Due Date</div><div class="value">{self.invoice.due_date.strftime('%b %d, %Y')}</div>
|
||||
<div class="label">Status</div><div class="value">{self.invoice.status.title()}</div>
|
||||
<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>
|
||||
@@ -141,13 +178,13 @@ class InvoicePDFGenerator:
|
||||
<!-- Client Information -->
|
||||
<div class="two-col">
|
||||
<div class="card">
|
||||
<div class="section-title">Bill To</div>
|
||||
<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 class="section-title">{_('Project')}</div>
|
||||
<div><strong>{self._escape(self.invoice.project.name)}</strong></div>
|
||||
{self._get_project_description_html()}
|
||||
</div>
|
||||
@@ -158,10 +195,10 @@ class InvoicePDFGenerator:
|
||||
<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>
|
||||
<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>
|
||||
@@ -179,7 +216,7 @@ class InvoicePDFGenerator:
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
{self._get_payment_info_html()}
|
||||
<div><strong>Terms & Conditions:</strong> {self._escape(self.settings.invoice_terms)}</div>
|
||||
<div><strong>{_('Terms & Conditions:')}</strong> {self._escape(self.settings.invoice_terms)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -296,7 +333,7 @@ class InvoicePDFGenerator:
|
||||
if self.invoice.notes:
|
||||
html_parts.append(f"""
|
||||
<div class="notes-section">
|
||||
<h4>Notes:</h4>
|
||||
<h4>{_('Notes:')}</h4>
|
||||
<p>{self.invoice.notes}</p>
|
||||
</div>
|
||||
""")
|
||||
@@ -304,7 +341,7 @@ class InvoicePDFGenerator:
|
||||
if self.invoice.terms:
|
||||
html_parts.append(f"""
|
||||
<div class="terms-section">
|
||||
<h4>Terms:</h4>
|
||||
<h4>{_('Terms:')}</h4>
|
||||
<p>{self.invoice.terms}</p>
|
||||
</div>
|
||||
""")
|
||||
@@ -324,7 +361,7 @@ class InvoicePDFGenerator:
|
||||
"""Generate HTML for payment information"""
|
||||
if self.settings.company_bank_info:
|
||||
return f"""
|
||||
<h4>Payment Information:</h4>
|
||||
<h4>{_('Payment Information:')}</h4>
|
||||
<div class="bank-info">{self.settings.company_bank_info}</div>
|
||||
"""
|
||||
return ''
|
||||
|
||||
@@ -14,6 +14,7 @@ from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
|
||||
from reportlab.pdfgen import canvas
|
||||
from app.models import Settings
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as _
|
||||
|
||||
class InvoicePDFGeneratorFallback:
|
||||
"""Generate PDF invoices with company branding using ReportLab"""
|
||||
@@ -158,10 +159,17 @@ class InvoicePDFGeneratorFallback:
|
||||
# Fallback to text if image fails
|
||||
invoice_info.append(Paragraph("[Company Logo]", self.styles['NormalText']))
|
||||
|
||||
invoice_info.append(Paragraph("INVOICE", self.styles['InvoiceTitle']))
|
||||
invoice_info.append(Paragraph(f"Invoice #: {self.invoice.invoice_number}", self.styles['NormalText']))
|
||||
invoice_info.append(Paragraph(f"Issue Date: {self.invoice.issue_date.strftime('%B %d, %Y')}", self.styles['NormalText']))
|
||||
invoice_info.append(Paragraph(f"Due Date: {self.invoice.due_date.strftime('%B %d, %Y')}", self.styles['NormalText']))
|
||||
invoice_info.append(Paragraph(_("INVOICE"), self.styles['InvoiceTitle']))
|
||||
invoice_info.append(Paragraph(_("Invoice #: %(num)s", num=self.invoice.invoice_number), self.styles['NormalText']))
|
||||
try:
|
||||
from babel.dates import format_date as babel_format_date
|
||||
issue_label = _("Issue Date: %(date)s", date=babel_format_date(self.invoice.issue_date))
|
||||
due_label = _("Due Date: %(date)s", date=babel_format_date(self.invoice.due_date))
|
||||
except Exception:
|
||||
issue_label = _("Issue Date: %(date)s", date=self.invoice.issue_date.strftime('%Y-%m-%d'))
|
||||
due_label = _("Due Date: %(date)s", date=self.invoice.due_date.strftime('%Y-%m-%d'))
|
||||
invoice_info.append(Paragraph(issue_label, self.styles['NormalText']))
|
||||
invoice_info.append(Paragraph(due_label, self.styles['NormalText']))
|
||||
invoice_info.append(Paragraph(f"Status: {self.invoice.status.title()}", self.styles['NormalText']))
|
||||
|
||||
# Create a table to layout company info and invoice info side by side
|
||||
@@ -205,10 +213,10 @@ class InvoicePDFGeneratorFallback:
|
||||
"""Build the invoice items table"""
|
||||
story = []
|
||||
|
||||
story.append(Paragraph("Invoice Items", self.styles['SectionHeader']))
|
||||
story.append(Paragraph(_("Invoice Items"), self.styles['SectionHeader']))
|
||||
|
||||
# Table headers
|
||||
headers = ['Description', 'Quantity (Hours)', 'Unit Price', 'Total Amount']
|
||||
headers = [_("Description"), _("Quantity (Hours)"), _("Unit Price"), _("Total Amount")]
|
||||
|
||||
# Table data
|
||||
data = [headers]
|
||||
@@ -222,12 +230,12 @@ class InvoicePDFGeneratorFallback:
|
||||
data.append(row)
|
||||
|
||||
# Add totals
|
||||
data.append(['', '', 'Subtotal:', self._format_currency(self.invoice.subtotal)])
|
||||
data.append(['', '', _('Subtotal:'), self._format_currency(self.invoice.subtotal)])
|
||||
|
||||
if self.invoice.tax_rate > 0:
|
||||
data.append(['', '', f'Tax ({self.invoice.tax_rate:.2f}%):', self._format_currency(self.invoice.tax_amount)])
|
||||
data.append(['', '', _('Tax (%(rate).2f%%):', rate=self.invoice.tax_rate), self._format_currency(self.invoice.tax_amount)])
|
||||
|
||||
data.append(['', '', 'Total Amount:', self._format_currency(self.invoice.total_amount)])
|
||||
data.append(['', '', _('Total Amount:'), self._format_currency(self.invoice.total_amount)])
|
||||
|
||||
# Create table
|
||||
table = Table(data, colWidths=[9*cm, 3*cm, 3*cm, 3*cm], repeatRows=1)
|
||||
@@ -273,12 +281,12 @@ class InvoicePDFGeneratorFallback:
|
||||
story = []
|
||||
|
||||
if self.invoice.notes:
|
||||
story.append(Paragraph("Notes:", self.styles['SectionHeader']))
|
||||
story.append(Paragraph(_("Notes:"), self.styles['SectionHeader']))
|
||||
story.append(Paragraph(self.invoice.notes, self.styles['NormalText']))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
if self.invoice.terms:
|
||||
story.append(Paragraph("Terms:", self.styles['SectionHeader']))
|
||||
story.append(Paragraph(_("Terms:"), self.styles['SectionHeader']))
|
||||
story.append(Paragraph(self.invoice.terms, self.styles['NormalText']))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
@@ -290,12 +298,12 @@ class InvoicePDFGeneratorFallback:
|
||||
|
||||
# Payment information
|
||||
if self.settings.company_bank_info:
|
||||
story.append(Paragraph("Payment Information:", self.styles['SectionHeader']))
|
||||
story.append(Paragraph(_("Payment Information:"), self.styles['SectionHeader']))
|
||||
story.append(Paragraph(self.settings.company_bank_info, self.styles['NormalText']))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
# Terms and conditions
|
||||
story.append(Paragraph("Terms & Conditions:", self.styles['SectionHeader']))
|
||||
story.append(Paragraph(_("Terms & Conditions:"), self.styles['SectionHeader']))
|
||||
story.append(Paragraph(self.settings.invoice_terms, self.styles['NormalText']))
|
||||
|
||||
return story
|
||||
|
||||
@@ -7,6 +7,7 @@ except Exception:
|
||||
_md = None
|
||||
bleach = None
|
||||
|
||||
|
||||
def register_template_filters(app):
|
||||
"""Register custom template filters for the application"""
|
||||
|
||||
@@ -68,3 +69,36 @@ def register_template_filters(app):
|
||||
'img': ['src', 'alt', 'title'],
|
||||
}
|
||||
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
|
||||
# Additional filters for PDFs / i18n-friendly formatting
|
||||
import datetime
|
||||
try:
|
||||
from babel.dates import format_date as babel_format_date
|
||||
except Exception:
|
||||
babel_format_date = None
|
||||
|
||||
@app.template_filter('format_date')
|
||||
def format_date_filter(value, format='medium'):
|
||||
if not value:
|
||||
return ''
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
try:
|
||||
if babel_format_date:
|
||||
if format == 'full':
|
||||
return babel_format_date(value, format='full')
|
||||
if format == 'long':
|
||||
return babel_format_date(value, format='long')
|
||||
if format == 'short':
|
||||
return babel_format_date(value, format='short')
|
||||
return babel_format_date(value, format='medium')
|
||||
return value.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
return value.strftime('%Y-%m-%d')
|
||||
return str(value)
|
||||
|
||||
@app.template_filter('format_money')
|
||||
def format_money_filter(value):
|
||||
try:
|
||||
return f"{float(value):,.2f}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add pdf template fields to settings
|
||||
|
||||
Revision ID: 012
|
||||
Revises: 011
|
||||
Create Date: 2025-09-12 00:00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '012'
|
||||
down_revision = '011'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'settings' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('settings')}
|
||||
if 'invoice_pdf_template_html' not in columns:
|
||||
op.add_column('settings', sa.Column('invoice_pdf_template_html', sa.Text(), nullable=True))
|
||||
if 'invoice_pdf_template_css' not in columns:
|
||||
op.add_column('settings', sa.Column('invoice_pdf_template_css', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'settings' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('settings')}
|
||||
if 'invoice_pdf_template_css' in columns:
|
||||
try:
|
||||
op.drop_column('settings', 'invoice_pdf_template_css')
|
||||
except Exception:
|
||||
pass
|
||||
if 'invoice_pdf_template_html' in columns:
|
||||
try:
|
||||
op.drop_column('settings', 'invoice_pdf_template_html')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('PDF Layout') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link href="{{ url_for('static', filename='vendor/grapesjs/grapes.min.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
#gjs { border: 1px solid #ddd; min-height: 520px; background: #fff; }
|
||||
#preview-frame { width: 100%; height: 520px; border: 1px solid #ddd; background: #fff; }
|
||||
.gjs-pn-panel.gjs-pn-views { display: none; }
|
||||
.editor-actions .btn { margin-right: .5rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0"><i class="fas fa-file-pdf text-primary"></i> {{ _('PDF Layout Editor') }}</h1>
|
||||
<div class="editor-actions">
|
||||
<button id="btn-load-defaults" class="btn btn-outline-secondary"><i class="fas fa-rotate"></i> {{ _('Load Defaults') }}</button>
|
||||
<button id="btn-save" class="btn btn-primary"><i class="fas fa-save"></i> {{ _('Save Layout') }}</button>
|
||||
<form id="form-reset" method="POST" action="{{ url_for('admin.pdf_layout_reset') }}" class="d-inline" onsubmit="return confirm('{{ _('Reset to defaults? This will clear custom HTML and CSS.') }}')">
|
||||
<button type="submit" class="btn btn-outline-danger"><i class="fas fa-undo"></i> {{ _('Reset') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-7 mb-3">
|
||||
<div id="gjs"></div>
|
||||
</div>
|
||||
<div class="col-lg-5 mb-3">
|
||||
<iframe id="preview-frame"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="form-save" method="POST" class="d-none">
|
||||
<textarea id="input-html" name="invoice_pdf_template_html"></textarea>
|
||||
<textarea id="input-css" name="invoice_pdf_template_css"></textarea>
|
||||
</form>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>{{ _('Notes') }}</strong></div>
|
||||
<div class="card-body small text-muted">
|
||||
<p>{{ _('Use the visual editor to drag-and-drop layout. You can switch to code view in the top-right of the editor.') }}</p>
|
||||
<p>{{ _('Variables (Jinja)') }}:</p>
|
||||
<ul>
|
||||
<li><code>{{ '{{ invoice.invoice_number }}' }}</code></li>
|
||||
<li><code>{{ '{{ format_date(invoice.issue_date) }}' }}</code></li>
|
||||
<li><code>{{ '{{ format_money(item.total_amount) }}' }}</code></li>
|
||||
<li><code>{{ '{{ settings.company_name }}' }}</code></li>
|
||||
<li><code>{{ "{{ _('<Label>') }}" }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script src="{{ url_for('static', filename='vendor/grapesjs/grapes.min.js') }}"></script>
|
||||
<script>
|
||||
// Pass server values for visual editor; keep next script block raw to avoid parsing issues
|
||||
const __INIT_HTML__ = {{ (initial_html or settings.invoice_pdf_template_html or '')|tojson }};
|
||||
const __INIT_CSS__ = {{ (initial_css or settings.invoice_pdf_template_css or '')|tojson }};
|
||||
const __DEFAULTS_URL__ = "{{ url_for('admin.pdf_layout_default') }}";
|
||||
const __PREVIEW_URL__ = "{{ url_for('admin.pdf_layout_preview') }}";
|
||||
</script>
|
||||
{% raw %}
|
||||
<script>
|
||||
(function(){
|
||||
// Fallback to plaintext editor if GrapesJS is unavailable (CDN blocked)
|
||||
if (!window.grapesjs) {
|
||||
var g = document.getElementById('gjs');
|
||||
if (g) {
|
||||
g.innerHTML = '<div class="mb-2"><label><strong>HTML</strong></label><textarea id="fb-html" class="form-control" rows="14"></textarea></div>' +
|
||||
'<div class="mb-2"><label><strong>CSS</strong></label><textarea id="fb-css" class="form-control" rows="10"></textarea></div>';
|
||||
var fbHtml = document.getElementById('fb-html');
|
||||
var fbCss = document.getElementById('fb-css');
|
||||
if (typeof __INIT_HTML__ !== 'undefined' && __INIT_HTML__) fbHtml.value = __INIT_HTML__;
|
||||
if (typeof __INIT_CSS__ !== 'undefined' && __INIT_CSS__) fbCss.value = __INIT_CSS__;
|
||||
|
||||
var btnDefaults = document.getElementById('btn-load-defaults');
|
||||
var btnSave = document.getElementById('btn-save');
|
||||
var btnPreview = document.getElementById('btn-preview');
|
||||
|
||||
if (btnDefaults) btnDefaults.addEventListener('click', function(){
|
||||
fetch(__DEFAULTS_URL__).then(function(r){return r.json();}).then(function(d){
|
||||
fbHtml.value = d.html || '';
|
||||
fbCss.value = d.css || '';
|
||||
}).catch(function(){});
|
||||
});
|
||||
if (btnSave) btnSave.addEventListener('click', function(){
|
||||
document.getElementById('input-html').value = fbHtml.value;
|
||||
document.getElementById('input-css').value = fbCss.value;
|
||||
document.getElementById('form-save').submit();
|
||||
});
|
||||
if (btnPreview) btnPreview.addEventListener('click', function(){
|
||||
var fd = new FormData();
|
||||
fd.append('html', fbHtml.value);
|
||||
fd.append('css', fbCss.value);
|
||||
fetch(__PREVIEW_URL__, { method:'POST', body: fd }).then(function(r){return r.text();}).then(function(html){
|
||||
var frame = document.getElementById('preview-frame');
|
||||
var doc = frame.contentDocument || frame.contentWindow.document;
|
||||
doc.open(); doc.write(html); doc.close();
|
||||
}).catch(function(){});
|
||||
});
|
||||
|
||||
if ((!__INIT_HTML__ || __INIT_HTML__ === '') && (!__INIT_CSS__ || __INIT_CSS__ === '')) {
|
||||
if (btnDefaults) btnDefaults.click();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = grapesjs.init({
|
||||
container: '#gjs',
|
||||
fromElement: false,
|
||||
height: '520px',
|
||||
storageManager: false,
|
||||
components: __INIT_HTML__ || '<div class="wrapper"><h1>{{ _("INVOICE") }} {{ invoice.invoice_number }}</h1></div>',
|
||||
style: __INIT_CSS__ || '',
|
||||
plugins: [],
|
||||
});
|
||||
|
||||
const snippets = {
|
||||
invoice_header: '<h1>{{ _("INVOICE") }} {{ invoice.invoice_number }}</h1>',
|
||||
items_table: '<table style="width:100%;border-collapse:collapse"><thead><tr><th>{{ _("Description") }}</th><th style="text-align:right">{{ _("Quantity (Hours)") }}</th><th style="text-align:right">{{ _("Unit Price") }}</th><th style="text-align:right">{{ _("Total Amount") }}</th></tr></thead><tbody>{% for item in invoice.items %}<tr><td>{{ item.description }}</td><td style="text-align:right">{{ "%.2f"|format(item.quantity) }}</td><td style="text-align:right">{{ format_money(item.unit_price) }}</td><td style="text-align:right">{{ format_money(item.total_amount) }}</td></tr>{% endfor %}</tbody></table>',
|
||||
subtotal_row: '<div><strong>{{ _("Subtotal:") }}</strong> {{ format_money(invoice.subtotal) }}</div>',
|
||||
terms: '<div><strong>{{ _("Terms & Conditions:") }}</strong> {{ settings.invoice_terms }}</div>'
|
||||
};
|
||||
|
||||
function loadDefaults(){
|
||||
fetch(__DEFAULTS_URL__).then(r=>r.json()).then(data=>{
|
||||
editor.setComponents(data.html || '');
|
||||
editor.setStyle(data.css || '');
|
||||
renderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function getHtmlCss(){
|
||||
const html = editor.getHtml();
|
||||
const css = editor.getCss();
|
||||
return { html, css };
|
||||
}
|
||||
|
||||
function renderPreview(){
|
||||
const { html, css } = getHtmlCss();
|
||||
const formData = new FormData();
|
||||
formData.append('html', html);
|
||||
formData.append('css', css);
|
||||
fetch(__PREVIEW_URL__, { method:'POST', body: formData })
|
||||
.then(r=>r.text()).then(html => {
|
||||
const frame = document.getElementById('preview-frame');
|
||||
const doc = frame.contentDocument || frame.contentWindow.document;
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btn-preview').addEventListener('click', function(){ renderPreview(); });
|
||||
document.getElementById('btn-load-defaults').addEventListener('click', function(){ loadDefaults(); });
|
||||
document.getElementById('btn-save').addEventListener('click', function(){
|
||||
const { html, css } = getHtmlCss();
|
||||
document.getElementById('input-html').value = html;
|
||||
document.getElementById('input-css').value = css;
|
||||
document.getElementById('form-save').submit();
|
||||
});
|
||||
document.querySelectorAll('[data-key]').forEach(btn=>{
|
||||
btn.addEventListener('click', function(e){
|
||||
e.preventDefault();
|
||||
const comp = editor.getSelected() || editor.getWrapper();
|
||||
const key = this.getAttribute('data-key');
|
||||
const tpl = snippets[key] || '';
|
||||
if (!tpl) return;
|
||||
comp.append(tpl);
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btn-insert-vars').addEventListener('click', function(){
|
||||
const menu = `
|
||||
<div class="p-2">
|
||||
<div class="mb-1"><code>{{ invoice.invoice_number }}</code></div>
|
||||
<div class="mb-1"><code>{{ format_date(invoice.issue_date) }}</code></div>
|
||||
<div class="mb-1"><code>{{ settings.company_name }}</code></div>
|
||||
<div class="mb-1"><code>{{ _("Label") }}</code></div>
|
||||
</div>`;
|
||||
alert(menu);
|
||||
});
|
||||
|
||||
if (!__INIT_HTML__ && !__INIT_CSS__) {
|
||||
loadDefaults();
|
||||
} else {
|
||||
renderPreview();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endraw %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-cog me-1"></i> {{ _('Configuration') }}</h5>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-cog me-1"></i> {{ _('Configuration') }}</h5>
|
||||
<a href="{{ url_for('admin.pdf_layout') }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-file-pdf"></i> {{ _('Edit PDF Layout') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
@bottom-center {
|
||||
content: "Page " counter(page) " of " counter(pages);
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
: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%; }
|
||||
|
||||
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; }
|
||||
|
||||
.time-entry-info { color:#6c757d; font-style:italic; }
|
||||
|
||||
Reference in New Issue
Block a user