mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-08 05:19:48 -05:00
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:
+120
-11
@@ -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>
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user