Browse Source

Bump to v2.0.8 multi-line invoice creation

main
def 3 weeks ago
parent
commit
26fbb6a4ff
  1. 12
      PROJECT_STATE.md
  2. 12
      README.md
  3. 2
      VERSION
  4. 361
      backend/app.py
  5. 192
      templates/invoices/new.html
  6. 15
      templates/invoices/view.html

12
PROJECT_STATE.md

@ -1,3 +1,15 @@
## v2.0.8 multi-line invoice creation - 2026-05-29 UTC
- Added multi-line invoice item creation on the admin Create Invoice page.
- Added “+ Add Line” support with per-line description, quantity, unit cost, and HST checkbox.
- Invoice subtotal is now calculated from the sum of all line totals.
- HST is calculated from taxable lines only.
- Invoice creation now inserts one invoice_items row per invoice line.
- Admin invoice view now displays all invoice line items with quantity, unit cost, and amount.
- Invoice PDFs now render all invoice line items instead of only the invoice-level notes field.
- Existing single-line invoices remain compatible.
- Fixed Create Invoice table header/input styling for dark theme readability.
## v2.0.7 PDF invoice description wrapping - 2026-05-29 UTC
- Fixed invoice PDFs so long invoice descriptions wrap across multiple lines instead of being truncated.

12
README.md

@ -1,3 +1,15 @@
## v2.0.8 multi-line invoice creation - 2026-05-29 UTC
- Added multi-line invoice item creation on the admin Create Invoice page.
- Added “+ Add Line” support with per-line description, quantity, unit cost, and HST checkbox.
- Invoice subtotal is now calculated from the sum of all line totals.
- HST is calculated from taxable lines only.
- Invoice creation now inserts one invoice_items row per invoice line.
- Admin invoice view now displays all invoice line items with quantity, unit cost, and amount.
- Invoice PDFs now render all invoice line items instead of only the invoice-level notes field.
- Existing single-line invoices remain compatible.
- Fixed Create Invoice table header/input styling for dark theme readability.
## v2.0.7 PDF invoice description wrapping - 2026-05-29 UTC
- Fixed invoice PDFs so long invoice descriptions wrap across multiple lines instead of being truncated.

2
VERSION

@ -1 +1 @@
v2.0.7
v2.0.8

361
backend/app.py

@ -3811,28 +3811,51 @@ def generate_next_invoice_number(cursor, prefix="INV"):
next_num += 1
@app.route("/invoices/new", methods=["GET", "POST"])
def new_invoice():
gate = admin_required()
if gate:
return gate
ensure_invoice_quote_columns()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
def _load_invoice_form_lists():
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
return clients, services
if request.method == "POST":
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
client_id = request.form.get("client_id", "").strip()
service_id = request.form.get("service_id", "").strip()
currency_code = request.form.get("currency_code", "").strip()
quantity_raw = request.form.get("quantity", "1").strip()
unit_amount_raw = request.form.get("unit_amount", "").strip()
currency_code = request.form.get("currency_code", "").strip() or "CAD"
due_at = request.form.get("due_at", "").strip()
line_description = request.form.get("description", "").strip()
apply_tax = request.form.get("apply_tax") == "1"
descriptions = request.form.getlist("item_description[]")
quantities = request.form.getlist("item_quantity[]")
unit_amounts = request.form.getlist("item_unit_amount[]")
taxables = request.form.getlist("item_taxable[]")
max_len = max(len(descriptions), len(quantities), len(unit_amounts), len(taxables), 1)
while len(descriptions) < max_len:
descriptions.append("")
while len(quantities) < max_len:
quantities.append("")
while len(unit_amounts) < max_len:
unit_amounts.append("")
while len(taxables) < max_len:
taxables.append("0")
errors = []
line_items = []
if not client_id:
errors.append("Client is required.")
@ -3840,68 +3863,92 @@ def new_invoice():
errors.append("Service is required.")
if not currency_code:
errors.append("Currency is required.")
if not quantity_raw:
errors.append("Quantity is required.")
if not unit_amount_raw:
errors.append("Unit cost is required.")
if not due_at:
errors.append("Due date is required.")
if not line_description:
errors.append("Description is required.")
quantity = None
unit_amount = None
subtotal_amount = None
subtotal_amount = Decimal("0.00000000")
tax_amount = Decimal("0.00000000")
total_amount = None
if not errors:
try:
quantity = Decimal(quantity_raw).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if quantity <= 0:
errors.append("Quantity must be greater than zero.")
except (InvalidOperation, ValueError):
errors.append("Quantity must be a valid number.")
for idx in range(max_len):
desc = (descriptions[idx] or "").strip()
qty_raw = (quantities[idx] or "").strip()
unit_raw = (unit_amounts[idx] or "").strip()
taxable = str(taxables[idx] or "0").strip() == "1"
try:
unit_amount = Decimal(unit_amount_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if unit_amount <= 0:
errors.append("Unit cost must be greater than zero.")
except (InvalidOperation, ValueError):
errors.append("Unit cost must be a valid number.")
if not desc and not qty_raw and not unit_raw:
continue
if not errors:
subtotal_amount = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if apply_tax:
tax_amount = (subtotal_amount * Decimal("0.13")).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
line_no = len(line_items) + 1
if errors:
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
if not desc:
errors.append(f"Line {line_no}: description is required.")
if not qty_raw:
errors.append(f"Line {line_no}: quantity is required.")
if not unit_raw:
errors.append(f"Line {line_no}: unit cost is required.")
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
quantity = None
unit_amount = None
conn.close()
if qty_raw:
try:
quantity = Decimal(qty_raw).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if quantity <= 0:
errors.append(f"Line {line_no}: quantity must be greater than zero.")
except (InvalidOperation, ValueError):
errors.append(f"Line {line_no}: quantity must be a valid number.")
form_data = {
"client_id": client_id,
"service_id": service_id,
"currency_code": currency_code,
"quantity": quantity_raw,
"unit_amount": unit_amount_raw,
"due_at": due_at,
"description": line_description,
"apply_tax": "1" if apply_tax else "",
}
if unit_raw:
try:
unit_amount = Decimal(unit_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if unit_amount <= 0:
errors.append(f"Line {line_no}: unit cost must be greater than zero.")
except (InvalidOperation, ValueError):
errors.append(f"Line {line_no}: unit cost must be a valid number.")
line_total = Decimal("0.00000000")
line_tax = Decimal("0.00000000")
if quantity is not None and unit_amount is not None:
line_total = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if taxable:
line_tax = (line_total * Decimal("0.13")).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
line_items.append({
"description": desc,
"quantity": str(quantity) if quantity is not None else qty_raw,
"unit_amount": str(unit_amount) if unit_amount is not None else unit_raw,
"line_total": str(line_total),
"taxable": "1" if taxable else "0",
"line_tax": str(line_tax),
})
subtotal_amount += line_total
tax_amount += line_tax
if not line_items:
errors.append("At least one invoice line is required.")
subtotal_amount = subtotal_amount.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
tax_amount = tax_amount.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if errors:
clients, services = _load_invoice_form_lists()
conn.close()
return render_template(
"invoices/new.html",
clients=clients,
services=services,
errors=errors,
form_data=form_data,
form_data={
"client_id": client_id,
"service_id": service_id,
"currency_code": currency_code,
"due_at": due_at,
"line_items": line_items,
},
)
oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, str(total_amount))
@ -3912,6 +3959,7 @@ def new_invoice():
insert_cursor = conn.cursor()
invoice_number = generate_next_invoice_number(insert_cursor)
notes = "\n".join([item["description"] for item in line_items]).strip()
insert_cursor.execute("""
INSERT INTO invoices
@ -3942,7 +3990,7 @@ def new_invoice():
subtotal_amount,
tax_amount,
due_at,
line_description,
notes,
quote_fiat_amount,
quote_fiat_currency,
quote_expires_at,
@ -3951,41 +3999,38 @@ def new_invoice():
invoice_id = insert_cursor.lastrowid
insert_cursor.execute("""
INSERT INTO invoice_items
(
for line_number, item in enumerate(line_items, start=1):
insert_cursor.execute("""
INSERT INTO invoice_items
(
invoice_id,
line_number,
item_type,
description,
quantity,
unit_amount,
line_total,
currency_code,
service_id
)
VALUES (%s, %s, 'service', %s, %s, %s, %s, %s, %s)
""", (
invoice_id,
line_number,
item_type,
description,
quantity,
unit_amount,
line_total,
item["description"],
item["quantity"],
item["unit_amount"],
item["line_total"],
currency_code,
service_id
)
VALUES (%s, 1, 'service', %s, %s, %s, %s, %s, %s)
""", (
invoice_id,
line_description,
quantity,
unit_amount,
subtotal_amount,
currency_code,
service_id
))
))
conn.commit()
conn.close()
return redirect("/invoices")
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
return redirect(f"/invoices/view/{invoice_id}")
clients, services = _load_invoice_form_lists()
conn.close()
return render_template(
@ -3993,12 +4038,15 @@ def new_invoice():
clients=clients,
services=services,
errors=[],
form_data={},
form_data={
"currency_code": "CAD",
"line_items": [
{"description": "", "quantity": "1", "unit_amount": "", "taxable": "0"}
],
},
)
@app.route("/invoices/pdf/<int:invoice_id>")
def invoice_pdf(invoice_id, client_copy=False):
conn = get_db_connection()
@ -4025,6 +4073,24 @@ def invoice_pdf(invoice_id, client_copy=False):
conn.close()
return "Invoice not found", 404
cursor.execute("""
SELECT line_number, description, quantity, unit_amount, line_total, currency_code
FROM invoice_items
WHERE invoice_id = %s
ORDER BY line_number ASC, id ASC
""", (invoice_id,))
invoice_items = cursor.fetchall()
if not invoice_items:
invoice_items = [{
"line_number": 1,
"description": invoice.get("notes") or "-",
"quantity": 1,
"unit_amount": invoice.get("subtotal_amount"),
"line_total": invoice.get("subtotal_amount"),
"currency_code": invoice.get("currency_code", "CAD"),
}]
conn.close()
settings = get_app_settings()
@ -4170,33 +4236,37 @@ def invoice_pdf(invoice_id, client_copy=False):
y -= 16
pdf.setFont("Helvetica", 10)
service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10)
service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10)
description_lines = pdf_wrap_text(invoice.get("notes") or "-", 265, "Helvetica", 10)
row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1)
line_amount = invoice.get("subtotal_amount")
if line_amount is None:
line_amount = invoice.get("total_amount")
for item in invoice_items:
service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10)
service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10)
description_lines = pdf_wrap_text(item.get("description") or "-", 265, "Helvetica", 10)
row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1)
for idx in range(row_count):
if y < 120:
pdf.showPage()
y = height - 50
line_amount = item.get("line_total")
if line_amount is None:
line_amount = invoice.get("subtotal_amount")
pdf.setFont("Helvetica", 10)
if idx < len(service_code_lines):
pdf.drawString(left, y, service_code_lines[idx])
if idx < len(service_lines):
pdf.drawString(125, y, service_lines[idx])
if idx < len(description_lines):
pdf.drawString(205, y, description_lines[idx])
if idx == 0:
pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD")))
for idx in range(row_count):
if y < 120:
pdf.showPage()
y = height - 50
y -= 13
pdf.setFont("Helvetica", 10)
if idx < len(service_code_lines):
pdf.drawString(left, y, service_code_lines[idx])
if idx < len(service_lines):
pdf.drawString(125, y, service_lines[idx])
if idx < len(description_lines):
pdf.drawString(205, y, description_lines[idx])
if idx == 0:
pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD")))
y -= 15
y -= 13
y -= 6
y -= 9
totals_x_label = 360
totals_x_value = right
@ -4380,6 +4450,24 @@ def view_invoice(invoice_id):
conn.close()
return "Invoice not found", 404
cursor.execute("""
SELECT line_number, description, quantity, unit_amount, line_total, currency_code
FROM invoice_items
WHERE invoice_id = %s
ORDER BY line_number ASC, id ASC
""", (invoice_id,))
invoice_items = cursor.fetchall()
if not invoice_items:
invoice_items = [{
"line_number": 1,
"description": invoice.get("notes") or "-",
"quantity": 1,
"unit_amount": invoice.get("subtotal_amount"),
"line_total": invoice.get("subtotal_amount"),
"currency_code": invoice.get("currency_code", "CAD"),
}]
conn.close()
settings = get_app_settings()
invoice_payments = get_invoice_payments(invoice_id)
@ -4387,7 +4475,8 @@ def view_invoice(invoice_id):
"invoices/view.html",
invoice=invoice,
settings=settings,
invoice_payments=invoice_payments
invoice_payments=invoice_payments,
invoice_items=invoice_items
)
@ -5538,6 +5627,24 @@ def portal_invoice_pdf(invoice_id):
conn.close()
return redirect("/portal/dashboard")
cursor.execute("""
SELECT line_number, description, quantity, unit_amount, line_total, currency_code
FROM invoice_items
WHERE invoice_id = %s
ORDER BY line_number ASC, id ASC
""", (invoice_id,))
invoice_items = cursor.fetchall()
if not invoice_items:
invoice_items = [{
"line_number": 1,
"description": invoice.get("notes") or "-",
"quantity": 1,
"unit_amount": invoice.get("subtotal_amount"),
"line_total": invoice.get("subtotal_amount"),
"currency_code": invoice.get("currency_code", "CAD"),
}]
conn.close()
settings = get_app_settings()
@ -5682,33 +5789,37 @@ def portal_invoice_pdf(invoice_id):
y -= 16
pdf.setFont("Helvetica", 10)
service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10)
service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10)
description_lines = pdf_wrap_text(invoice.get("notes") or "-", 265, "Helvetica", 10)
row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1)
line_amount = invoice.get("subtotal_amount")
if line_amount is None:
line_amount = invoice.get("total_amount")
for item in invoice_items:
service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10)
service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10)
description_lines = pdf_wrap_text(item.get("description") or "-", 265, "Helvetica", 10)
row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1)
for idx in range(row_count):
if y < 120:
pdf.showPage()
y = height - 50
line_amount = item.get("line_total")
if line_amount is None:
line_amount = invoice.get("subtotal_amount")
pdf.setFont("Helvetica", 10)
if idx < len(service_code_lines):
pdf.drawString(left, y, service_code_lines[idx])
if idx < len(service_lines):
pdf.drawString(125, y, service_lines[idx])
if idx < len(description_lines):
pdf.drawString(205, y, description_lines[idx])
if idx == 0:
pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD")))
for idx in range(row_count):
if y < 120:
pdf.showPage()
y = height - 50
y -= 13
pdf.setFont("Helvetica", 10)
if idx < len(service_code_lines):
pdf.drawString(left, y, service_code_lines[idx])
if idx < len(service_lines):
pdf.drawString(125, y, service_lines[idx])
if idx < len(description_lines):
pdf.drawString(205, y, description_lines[idx])
if idx == 0:
pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD")))
y -= 15
y -= 13
y -= 6
y -= 9
totals_x_label = 360
totals_x_value = right

192
templates/invoices/new.html

@ -2,7 +2,20 @@
<html>
<head>
<title>New Invoice</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="icon" type="image/png" href="/static/favicon.png">
<style>
body { font-family: Arial, sans-serif; margin: 30px; }
.invoice-lines { width: 100%; border-collapse: collapse; margin: 15px 0; }
.invoice-lines th, .invoice-lines td { border: 1px solid #ccc; padding: 8px; vertical-align: top; }
.invoice-lines th { background: #f3f4f6; color: #111827; text-align: left; }
.invoice-lines textarea { width: 100%; min-height: 70px; }
.invoice-lines input[type="number"] { width: 120px; }
.invoice-lines input, .invoice-lines textarea { background: #ffffff; color: #111827; }
.summary-box { border:1px solid #ccc; padding:10px; max-width:420px; margin:15px 0; }
.summary-box table { width: 100%; }
.summary-box th { text-align:left; padding-right:20px; }
.remove-line { color: #991b1b; }
</style>
</head>
<body>
@ -60,65 +73,44 @@ Currency *<br>
</select>
</p>
<p>
Description *<br>
<textarea name="description" rows="5" cols="80" required>{{ form_data.get('description', '') }}</textarea>
</p>
<p>
Qty *<br>
<input
type="number"
step="0.0001"
min="0.0001"
name="quantity"
id="quantity"
value="{{ form_data.get('quantity', '1') }}"
required
>
</p>
<p>
Unit Cost *<br>
<input
type="number"
step="0.00000001"
min="0.00000001"
name="unit_amount"
id="unit_amount"
value="{{ form_data.get('unit_amount', '') }}"
required
>
</p>
<h2>Invoice Lines</h2>
<table class="invoice-lines">
<thead>
<tr>
<th style="width:50%;">Description *</th>
<th>Qty *</th>
<th>Unit Cost *</th>
<th>HST</th>
<th>Line Total</th>
<th></th>
</tr>
</thead>
<tbody id="invoice-lines-body">
{% set line_items = form_data.get('line_items') or [{'description':'', 'quantity':'1', 'unit_amount':'', 'taxable':'0'}] %}
{% for item in line_items %}
<tr class="invoice-line">
<td><textarea name="item_description[]" required>{{ item.description }}</textarea></td>
<td><input type="number" step="0.0001" min="0.0001" name="item_quantity[]" class="line-qty" value="{{ item.quantity or '1' }}" required></td>
<td><input type="number" step="0.00000001" min="0.00000001" name="item_unit_amount[]" class="line-unit" value="{{ item.unit_amount or '' }}" required></td>
<td>
<input type="hidden" name="item_taxable[]" class="line-taxable-hidden" value="{{ item.taxable or '0' }}">
<input type="checkbox" class="line-taxable" value="1" {% if item.taxable == '1' %}checked{% endif %}>
</td>
<td class="line-total">0.00 CAD</td>
<td><button type="button" class="remove-line">Remove</button></td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<label>
<input
type="checkbox"
name="apply_tax"
id="apply_tax"
value="1"
{% if form_data.get('apply_tax') == '1' %}checked{% endif %}
>
Apply 13% HST
</label>
</p>
<p><button type="button" id="add-line">+ Add Line</button></p>
<div style="border:1px solid #ccc; padding:10px; max-width:420px; margin:15px 0;">
<div class="summary-box">
<strong>Invoice Summary</strong><br><br>
<table>
<tr>
<th style="text-align:left; padding-right:20px;">Subtotal</th>
<td id="summary_subtotal">0.00 CAD</td>
</tr>
<tr id="summary_tax_row" style="display:none;">
<th style="text-align:left; padding-right:20px;">HST 13%</th>
<td id="summary_tax">0.00 CAD</td>
</tr>
<tr>
<th style="text-align:left; padding-right:20px;">Total Amount</th>
<td><strong id="summary_total">0.00 CAD</strong></td>
</tr>
<tr><th>Subtotal</th><td id="summary_subtotal">0.00 CAD</td></tr>
<tr id="summary_tax_row" style="display:none;"><th>HST 13%</th><td id="summary_tax">0.00 CAD</td></tr>
<tr><th>Total Amount</th><td><strong id="summary_total">0.00 CAD</strong></td></tr>
</table>
</div>
@ -127,47 +119,95 @@ Due Date *<br>
<input type="date" name="due_at" value="{{ form_data.get('due_at', '') }}" required>
</p>
<p>
<button type="submit">Create Invoice</button>
</p>
<p><button type="submit">Create Invoice</button></p>
</form>
<script>
(function () {
const qtyInput = document.getElementById("quantity");
const unitInput = document.getElementById("unit_amount");
const taxBox = document.getElementById("apply_tax");
const body = document.getElementById("invoice-lines-body");
const addBtn = document.getElementById("add-line");
const currencySelect = document.getElementById("currency_code");
const subtotalEl = document.getElementById("summary_subtotal");
const taxRow = document.getElementById("summary_tax_row");
const taxEl = document.getElementById("summary_tax");
const totalEl = document.getElementById("summary_total");
function decimals() {
return (currencySelect.value || "CAD") === "CAD" ? 2 : 8;
}
function money(value) {
const currency = currencySelect.value || "CAD";
const decimals = currency === "CAD" ? 2 : 8;
const n = Number(value || 0);
return n.toFixed(decimals) + " " + currency;
return n.toFixed(decimals()) + " " + (currencySelect.value || "CAD");
}
function bindRow(row) {
const taxableBox = row.querySelector(".line-taxable");
const taxableHidden = row.querySelector(".line-taxable-hidden");
taxableBox.addEventListener("change", function () {
taxableHidden.value = taxableBox.checked ? "1" : "0";
updateSummary();
});
row.querySelectorAll("input, textarea").forEach(function (el) {
el.addEventListener("input", updateSummary);
el.addEventListener("change", updateSummary);
});
row.querySelector(".remove-line").addEventListener("click", function () {
if (body.querySelectorAll(".invoice-line").length > 1) {
row.remove();
updateSummary();
}
});
}
function updateSummary() {
const qty = Number(qtyInput.value || 0);
const unit = Number(unitInput.value || 0);
const subtotal = qty * unit;
const applyTax = taxBox.checked;
const tax = applyTax ? subtotal * 0.13 : 0;
const total = subtotal + tax;
let subtotal = 0;
let tax = 0;
body.querySelectorAll(".invoice-line").forEach(function (row) {
const qty = Number(row.querySelector(".line-qty").value || 0);
const unit = Number(row.querySelector(".line-unit").value || 0);
const lineTotal = qty * unit;
const taxable = row.querySelector(".line-taxable").checked;
subtotal += lineTotal;
if (taxable) {
tax += lineTotal * 0.13;
}
row.querySelector(".line-total").textContent = money(lineTotal);
row.querySelector(".line-taxable-hidden").value = taxable ? "1" : "0";
});
subtotalEl.textContent = money(subtotal);
taxEl.textContent = money(tax);
totalEl.textContent = money(total);
taxRow.style.display = applyTax ? "table-row" : "none";
totalEl.textContent = money(subtotal + tax);
taxRow.style.display = tax > 0 ? "table-row" : "none";
}
qtyInput.addEventListener("input", updateSummary);
unitInput.addEventListener("input", updateSummary);
taxBox.addEventListener("change", updateSummary);
addBtn.addEventListener("click", function () {
const first = body.querySelector(".invoice-line");
const clone = first.cloneNode(true);
clone.querySelectorAll("textarea").forEach(el => el.value = "");
clone.querySelectorAll("input").forEach(function (el) {
if (el.classList.contains("line-qty")) el.value = "1";
else if (el.classList.contains("line-unit")) el.value = "";
else if (el.classList.contains("line-taxable-hidden")) el.value = "0";
else if (el.classList.contains("line-taxable")) el.checked = false;
});
clone.querySelector(".line-total").textContent = money(0);
body.appendChild(clone);
bindRow(clone);
updateSummary();
});
body.querySelectorAll(".invoice-line").forEach(bindRow);
currencySelect.addEventListener("change", updateSummary);
updateSummary();
})();

15
templates/invoices/view.html

@ -170,14 +170,29 @@ body {
<th>Service Code</th>
<th>Service</th>
<th>Description</th>
<th>Qty</th>
<th>Unit Cost</th>
<th>Amount</th>
</tr>
{% for item in invoice_items %}
<tr>
<td>{{ invoice.service_code or '-' }}</td>
<td>{{ invoice.service_name or '-' }}</td>
<td>{{ item.description or '-' }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unit_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
<td>{{ item.line_total|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
{% else %}
<tr>
<td>{{ invoice.service_code or '-' }}</td>
<td>{{ invoice.service_name or '-' }}</td>
<td>{{ invoice.notes or '-' }}</td>
<td>1</td>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
{% endfor %}
</table>
<table class="total-table">

Loading…
Cancel
Save