feat(pdf-editor): Add grid snapping and expenses table support

Add snap-to-grid functionality with visual grid overlay:
- 10px grid with toggle checkbox in action bar
- Visual grid lines (light gray, bolder every 50px)
- Elements snap to grid during drag operations
- Position updates in properties panel after dragging

Add Expenses Table element for invoice customization:
- New table element in sidebar with amber/yellow theme
- Displays expense title, date, category, and amount
- Loops through invoice.expenses using Jinja2 templating
- Backend support for Query-to-list conversion in preview and PDF generation

Clean up debug logging:
- Remove console.log statements from JavaScript
- Remove print debug statements from Python endpoints
- Clean up pdf_layout_preview and related functions

Backend changes:
- Convert invoice.expenses from SQLAlchemy Query to list in admin.py
- Add expenses data support in pdf_generator.py
- Update generateCode() to handle both items-table and expenses-table

Improves UX with precise element positioning and adds support for
displaying project expenses alongside invoice items in custom PDF layouts.
This commit is contained in:
Dries Peeters
2025-10-31 13:09:04 +01:00
parent 04760170c2
commit aa7e78c0f9
3 changed files with 331 additions and 33 deletions
+120 -11
View File
@@ -396,6 +396,7 @@ def pdf_layout():
html_template = request.form.get('invoice_pdf_template_html', '')
css_template = request.form.get('invoice_pdf_template_css', '')
design_json = request.form.get('design_json', '')
settings_obj.invoice_pdf_template_html = html_template
settings_obj.invoice_pdf_template_css = css_template
settings_obj.invoice_pdf_design_json = design_json
@@ -437,6 +438,7 @@ def pdf_layout():
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 = ''
settings_obj.invoice_pdf_design_json = ''
@@ -447,6 +449,55 @@ def pdf_layout_reset():
return redirect(url_for('admin.pdf_layout'))
@admin_bp.route('/admin/pdf-layout/debug', methods=['GET'])
@login_required
@admin_or_permission_required('manage_settings')
def pdf_layout_debug():
"""Debug endpoint to show what's saved in the database"""
settings_obj = Settings.get_settings()
html = settings_obj.invoice_pdf_template_html or ''
css = settings_obj.invoice_pdf_template_css or ''
design_json = settings_obj.invoice_pdf_design_json or ''
# Check for bugs
has_all_bug = 'invoice.items.all()' in html
has_if_bug = 'invoice.items and invoice.items.all()' in html
# Get invoice info for testing
from app.models import Invoice
test_invoice = Invoice.query.order_by(Invoice.id.desc()).first()
debug_info = {
'saved_template': {
'html_length': len(html),
'css_length': len(css),
'design_json_length': len(design_json),
'has_html': bool(html),
'has_bugs': has_all_bug or has_if_bug,
'bugs_found': []
},
'test_invoice': {
'exists': test_invoice is not None,
'invoice_number': test_invoice.invoice_number if test_invoice else None,
'items_count': test_invoice.items.count() if test_invoice else 0,
}
}
if has_all_bug:
debug_info['saved_template']['bugs_found'].append('invoice.items.all() found in template')
if has_if_bug:
debug_info['saved_template']['bugs_found'].append('invoice.items and invoice.items.all() found in template')
# Show snippets of problematic code
if has_all_bug or has_if_bug:
import re
matches = re.finditer(r'.{0,50}invoice\.items\.all\(\).{0,50}', html)
debug_info['saved_template']['bug_snippets'] = [m.group() for m in matches]
return jsonify(debug_info)
@admin_bp.route('/admin/pdf-layout/default', methods=['GET'])
@login_required
@admin_or_permission_required('manage_settings')
@@ -516,21 +567,77 @@ def pdf_layout_preview():
)
# 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:
# Create a wrapper object with converted Query objects to lists
# We can't modify SQLAlchemy model attributes directly, so we create a wrapper
invoice_wrapper = SimpleNamespace()
# Copy all simple attributes from the invoice
for attr in ['id', 'invoice_number', 'project_id', 'client_name', 'client_email',
'client_address', 'client_id', 'issue_date', 'due_date', 'status',
'subtotal', 'tax_rate', 'tax_amount', 'total_amount', 'currency_code',
'notes', 'terms', 'payment_date', 'payment_method', 'payment_reference',
'payment_notes', 'amount_paid', 'payment_status', 'created_by',
'created_at', 'updated_at']:
try:
invoice.items = [sample_item]
except Exception:
setattr(invoice_wrapper, attr, getattr(invoice, attr))
except AttributeError:
pass
# Ensure extra_goods attribute exists
# Copy relationship attributes (project, client)
try:
if not hasattr(invoice, 'extra_goods'):
invoice.extra_goods = []
invoice_wrapper.project = invoice.project
except:
invoice_wrapper.project = SimpleNamespace(name='Sample Project', description='')
try:
invoice_wrapper.client = invoice.client
except:
invoice_wrapper.client = None
# Convert items from Query to list
try:
if hasattr(invoice, 'items') and hasattr(invoice.items, 'all'):
# It's a SQLAlchemy Query object - call .all() to get list
items_list = invoice.items.all()
if not items_list:
# No items in database, add sample
items_list = [sample_item]
invoice_wrapper.items = items_list
elif hasattr(invoice, 'items') and isinstance(invoice.items, list):
# Already a list
invoice_wrapper.items = invoice.items if invoice.items else [sample_item]
else:
# Fallback
invoice_wrapper.items = [sample_item]
except Exception as e:
print(f"Error converting invoice items: {e}")
invoice_wrapper.items = [sample_item]
# Convert extra_goods from Query to list
try:
if hasattr(invoice, 'extra_goods') and hasattr(invoice.extra_goods, 'all'):
invoice_wrapper.extra_goods = invoice.extra_goods.all()
elif hasattr(invoice, 'extra_goods') and isinstance(invoice.extra_goods, list):
invoice_wrapper.extra_goods = invoice.extra_goods
else:
invoice_wrapper.extra_goods = []
except Exception:
pass
invoice_wrapper.extra_goods = []
# Convert expenses from Query to list
try:
if hasattr(invoice, 'expenses') and hasattr(invoice.expenses, 'all'):
invoice_wrapper.expenses = invoice.expenses.all()
elif hasattr(invoice, 'expenses') and isinstance(invoice.expenses, list):
invoice_wrapper.expenses = invoice.expenses
else:
invoice_wrapper.expenses = []
except Exception:
invoice_wrapper.expenses = []
# Use the wrapper instead of the original invoice
invoice = invoice_wrapper
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
def _sanitize_jinja_blocks(raw: str) -> str:
try:
@@ -624,7 +731,9 @@ def pdf_layout_preview():
item=sample_item,
)
except Exception as e:
body_html = f"<div style='color:red'>Template error: {str(e)}</div>" + sanitized
import traceback
error_details = traceback.format_exc()
body_html = f"<div style='color:red; padding:20px; border:2px solid red; margin:20px;'><h3>Template error:</h3><pre>{str(e)}</pre><pre>{error_details}</pre></div>" + sanitized
# Build complete HTML page with embedded styles
page_html = f"""<!DOCTYPE html>
<html>
+48 -2
View File
@@ -79,12 +79,58 @@ class InvoicePDFGenerator:
except Exception:
return str(value)
# Convert lazy='dynamic' relationships to lists for template rendering
# This ensures {% for item in invoice.items %} works correctly
try:
if hasattr(self.invoice.items, 'all'):
# It's a SQLAlchemy Query object - need to call .all()
invoice_items = self.invoice.items.all()
else:
# Already a list or other iterable
invoice_items = list(self.invoice.items) if self.invoice.items else []
except Exception:
invoice_items = []
try:
if hasattr(self.invoice.extra_goods, 'all'):
# It's a SQLAlchemy Query object - need to call .all()
invoice_extra_goods = self.invoice.extra_goods.all()
else:
# Already a list or other iterable
invoice_extra_goods = list(self.invoice.extra_goods) if self.invoice.extra_goods else []
except Exception:
invoice_extra_goods = []
# Create a wrapper object that has the converted lists
from types import SimpleNamespace
invoice_data = SimpleNamespace()
# Copy all attributes from original invoice
for attr in dir(self.invoice):
if not attr.startswith('_'):
try:
setattr(invoice_data, attr, getattr(self.invoice, attr))
except Exception:
pass
# Override with converted lists
invoice_data.items = invoice_items
invoice_data.extra_goods = invoice_extra_goods
# Convert expenses from Query to list
try:
if hasattr(self.invoice, 'expenses') and hasattr(self.invoice.expenses, 'all'):
invoice_expenses = self.invoice.expenses.all()
else:
invoice_expenses = list(self.invoice.expenses) if self.invoice.expenses else []
except Exception:
invoice_expenses = []
invoice_data.expenses = invoice_expenses
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,
invoice=invoice_data, # Use wrapped object with lists
settings=self.settings,
Path=Path,
get_logo_base64=get_logo_base64,
@@ -101,7 +147,7 @@ class InvoicePDFGenerator:
if not html:
try:
html = render_template('invoices/pdf_default.html',
invoice=self.invoice,
invoice=invoice_data, # Use wrapped object with lists
settings=self.settings,
Path=Path,
get_logo_base64=get_logo_base64,
+163 -20
View File
@@ -412,6 +412,10 @@
<button id="btn-code" type="button" class="btn btn-secondary">
<i class="fas fa-code mr-2"></i>{{ _('View Code') }}
</button>
<label class="inline-flex items-center ml-4">
<input type="checkbox" id="snap-to-grid" class="mr-2" checked>
<span>{{ _('Snap to Grid (10px)') }}</span>
</label>
<form id="form-reset" method="POST" action="{{ url_for('admin.pdf_layout_reset') }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" onclick="confirmResetPdfLayout()" class="btn btn-danger">
@@ -546,6 +550,10 @@
<i class="fas fa-table"></i>
<span>{{ _('Items Table') }}</span>
</div>
<div class="element-item" data-type="expenses-table">
<i class="fas fa-table"></i>
<span>{{ _('Expenses Table') }}</span>
</div>
<div class="element-item" data-type="subtotal">
<i class="fas fa-coins"></i>
<span>{{ _('Subtotal') }}</span>
@@ -822,7 +830,7 @@
<div class="element-group">
<div class="element-group-title">{{ _('Invoice Items Loop') }}</div>
<div class="variable-item" data-variable="for-loop-start">
<div class="variable-name">{{ '{%' }} for item in invoice.items.all() {{ '%}' }}</div>
<div class="variable-name">{{ '{%' }} for item in invoice.items {{ '%}' }}</div>
<div class="variable-desc">{{ _('Loop through invoice items') }}</div>
</div>
<div class="variable-item" data-variable="item.description">
@@ -996,8 +1004,10 @@ function initializePDFEditor() {
// Store elements for export
const elements = [];
let selectedElement = null;
let snapToGrid = true;
const gridSize = 10;
// Add background
// Add background with grid
let background = new Konva.Rect({
x: 0,
y: 0,
@@ -1008,6 +1018,43 @@ function initializePDFEditor() {
});
layer.add(background);
// Add grid lines
function drawGrid() {
// Remove old grid if exists
layer.find('.grid-line').forEach(line => line.destroy());
// Draw vertical lines
for (let i = 0; i < width / gridSize; i++) {
const line = new Konva.Line({
points: [i * gridSize, 0, i * gridSize, height],
stroke: '#e0e0e0',
strokeWidth: i % 5 === 0 ? 0.5 : 0.25,
name: 'grid-line',
listening: false
});
layer.add(line);
line.moveToBottom();
}
// Draw horizontal lines
for (let i = 0; i < height / gridSize; i++) {
const line = new Konva.Line({
points: [0, i * gridSize, width, i * gridSize],
stroke: '#e0e0e0',
strokeWidth: i % 5 === 0 ? 0.5 : 0.25,
name: 'grid-line',
listening: false
});
layer.add(line);
line.moveToBottom();
}
background.moveToBottom();
layer.draw();
}
drawGrid();
// Element templates
{% raw %}
const templates = {
@@ -1118,7 +1165,7 @@ function initializePDFEditor() {
// Handle special loop variables
if (variable === 'for-loop-start') {
{% raw %}
text = '{% for item in invoice.items.all() %}';
text = '{% for item in invoice.items %}';
{% endraw %}
} else if (variable === 'for-loop-end') {
{% raw %}
@@ -1254,6 +1301,11 @@ function initializePDFEditor() {
return;
}
if (type === 'expenses-table') {
addExpensesTable(x, y);
return;
}
// Handle text-based elements
if (!template) return;
@@ -1302,7 +1354,7 @@ function initializePDFEditor() {
{% raw %}
const items = new Konva.Text({
y: 25,
text: '{% for item in invoice.items.all() %}\\n{{ item.description }} | {{ item.quantity }} | {{ format_money(item.unit_price) }} | {{ format_money(item.total_amount) }}\\n{% endfor %}',
text: '{% for item in invoice.items %}\\n{{ item.description }} | {{ item.quantity }} | {{ format_money(item.unit_price) }} | {{ format_money(item.total_amount) }}\\n{% endfor %}',
fontSize: 11,
fill: 'black',
width: 500
@@ -1316,12 +1368,74 @@ function initializePDFEditor() {
layer.draw();
}
function addExpensesTable(x, y) {
const tableGroup = new Konva.Group({
x: x,
y: y,
draggable: true,
name: 'expenses-table'
});
const header = new Konva.Text({
text: 'Expense | Date | Category | Amount',
fontSize: 12,
fontStyle: 'bold',
fill: '#856404',
width: 500
});
const line = new Konva.Line({
points: [0, 20, 500, 20],
stroke: '#856404',
strokeWidth: 1
});
{% raw %}
const items = new Konva.Text({
y: 25,
text: '{% for expense in invoice.expenses %}\\n{{ expense.title }} | {{ expense.expense_date }} | {{ expense.category }} | {{ format_money(expense.total_amount) }}\\n{% endfor %}',
fontSize: 11,
fill: '#856404',
width: 500
});
{% endraw %}
tableGroup.add(header, line, items);
layer.add(tableGroup);
elements.push({ type: 'expenses-table', node: tableGroup });
setupSelection(tableGroup);
layer.draw();
}
function setupSelection(node) {
node.on('click', function() {
selectElement(node);
});
// Add snap to grid on drag
node.on('dragmove', function() {
if (snapToGrid) {
node.position({
x: Math.round(node.x() / gridSize) * gridSize,
y: Math.round(node.y() / gridSize) * gridSize
});
}
});
node.on('dragend', function() {
// Update properties panel if visible
const propX = document.getElementById('prop-x');
const propY = document.getElementById('prop-y');
if (propX) propX.value = Math.round(node.x());
if (propY) propY.value = Math.round(node.y());
});
}
// Snap to grid toggle
document.getElementById('snap-to-grid')?.addEventListener('change', function(e) {
snapToGrid = e.target.checked;
});
function selectElement(node) {
// Remove previous selection
layer.find('Transformer').forEach(t => t.destroy());
@@ -1597,11 +1711,6 @@ function initializePDFEditor() {
document.getElementById('btn-preview').addEventListener('click', function() {
const { html, css } = generateCode();
// Debug logging
console.log('=== GENERATING PREVIEW ===');
console.log('Has Items Table:', html.includes('<!-- Items Table Start -->'));
console.log('Has Jinja2 loop:', html.includes('{' + '% for item in invoice.items'));
const loading = document.getElementById('preview-loading');
loading.classList.add('active');
@@ -1630,7 +1739,7 @@ function initializePDFEditor() {
function generateCode() {
let bodyContent = '';
layer.children.forEach(child => {
layer.children.forEach((child, index) => {
if (child === background || child.className === 'Transformer') return;
const attrs = child.attrs;
@@ -1687,24 +1796,24 @@ function initializePDFEditor() {
bodyContent += ` <hr class="line-element" style="position:absolute;left:${adjustedX}px;top:${adjustedY}px;width:${width}px;border:none;border-top:${strokeWidth}px solid ${stroke};margin:0;opacity:${opacity}">\n`;
}
} else if (child.className === 'Group') {
} else if (child.className === 'Group' || child.constructor.name === 'Group' || child.children) {
// It's a Group element (check multiple ways since className might be undefined after JSON restore)
// Check if this is a table by looking at the group's name
let isTable = child.attrs.name === 'items-table';
let isItemsTable = child.attrs.name === 'items-table';
let isExpensesTable = child.attrs.name === 'expenses-table';
// Fallback: Check if group has multiple children (header, line, items) - likely a table
if (!isTable && child.children && child.children.length >= 3) {
if (!isItemsTable && !isExpensesTable && child.children && child.children.length >= 3) {
const hasTextChildren = child.children.filter(c => c.className === 'Text').length >= 2;
const hasLine = child.children.some(c => c.className === 'Line');
if (hasTextChildren && hasLine) {
console.log('⚠ Table detected by structure (missing name attribute) - consider re-saving layout');
isTable = true;
isItemsTable = true;
}
}
if (isTable) {
// Generate proper HTML table
console.log('✓ Rendering items table at position', x, y);
console.log('Table group attrs:', child.attrs);
if (isItemsTable) {
// Generate proper HTML table for invoice items
bodyContent += ` <!-- Items Table Start -->\n`;
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:white;">\n`;
@@ -1718,8 +1827,8 @@ function initializePDFEditor() {
bodyContent += ` </thead>\n`;
bodyContent += ` <tbody>\n`;
{% raw %}
bodyContent += ` {% if invoice.items and invoice.items.all() %}\n`;
bodyContent += ` {% for item in invoice.items.all() %}\n`;
bodyContent += ` {% if invoice.items %}\n`;
bodyContent += ` {% for item in invoice.items %}\n`;
bodyContent += ` <tr style="border-bottom:1px solid #ddd;">\n`;
bodyContent += ` <td style="padding:10px;vertical-align:top;">{{ item.description }}</td>\n`;
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;">{{ item.quantity }}</td>\n`;
@@ -1737,6 +1846,40 @@ function initializePDFEditor() {
bodyContent += ` </table>\n`;
bodyContent += ` </div>\n`;
bodyContent += ` <!-- Items Table End -->\n`;
} else if (isExpensesTable) {
// Generate proper HTML table for project expenses
bodyContent += ` <!-- Expenses Table Start -->\n`;
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:#fffbf0;">\n`;
bodyContent += ` <thead>\n`;
bodyContent += ` <tr style="background-color:#fff3cd;border-bottom:2px solid #856404;">\n`;
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:#856404;">Expense</th>\n`;
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">Date</th>\n`;
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">Category</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">Amount</th>\n`;
bodyContent += ` </tr>\n`;
bodyContent += ` </thead>\n`;
bodyContent += ` <tbody>\n`;
{% raw %}
bodyContent += ` {% if invoice.expenses %}\n`;
bodyContent += ` {% for expense in invoice.expenses %}\n`;
bodyContent += ` <tr style="border-bottom:1px solid #f0e5c1;">\n`;
bodyContent += ` <td style="padding:10px;vertical-align:top;color:#856404;">{{ expense.title }}</td>\n`;
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;color:#856404;">{{ expense.expense_date }}</td>\n`;
bodyContent += ` <td style="padding:10px;vertical-align:top;color:#856404;">{{ expense.category }}</td>\n`;
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;color:#856404;">{{ format_money(expense.total_amount) }}</td>\n`;
bodyContent += ` </tr>\n`;
bodyContent += ` {% endfor %}\n`;
bodyContent += ` {% else %}\n`;
bodyContent += ` <tr>\n`;
bodyContent += ` <td colspan="4" style="padding:10px;text-align:center;color:#999;">No expenses</td>\n`;
bodyContent += ` </tr>\n`;
bodyContent += ` {% endif %}\n`;
{% endraw %}
bodyContent += ` </tbody>\n`;
bodyContent += ` </table>\n`;
bodyContent += ` </div>\n`;
bodyContent += ` <!-- Expenses Table End -->\n`;
} else {
// Regular group (not a table)
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity}">\n`;