diff --git a/README.md b/README.md index 76e8096..ec897f4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/models/settings.py b/app/models/settings.py index 919bc59..0f51b56 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -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 diff --git a/app/routes/admin.py b/app/routes/admin.py index 1d07d37..2139c64 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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'
]*>([\s\S]*?)', 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']*>([\s\S]*?)', html_src, _re.IGNORECASE) + if match: + html_src = match.group(1).strip() + except Exception: + pass + except Exception: + html_src = '","