Add payslip feature: detail page, PDF generation, and email to Spark
- core/utils.py: render_to_pdf() wrapper for xhtml2pdf - core/templates/core/pdf/payslip_pdf.html: A4 PDF payslip (matches V2 layout) - core/templates/core/email/payslip_email.html: HTML email body for Spark - core/templates/core/payslip.html: browser payslip detail page with print - core/views.py: add payslip_detail view, wire email+PDF into process_payment - core/urls.py: add payroll/payslip/<pk>/ route - config/settings.py: add SPARK_RECEIPT_EMAIL setting - payroll_dashboard.html: add "View" payslip link in Payment History tab All templates show adjustments (bonuses, deductions, overtime, loan repayments) as line items. Amounts always show 2 decimal places. Email failure does not roll back payment — handled gracefully with warning message. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1681ed26a2
commit
c8c78dd88e
@ -175,6 +175,10 @@ CONTACT_EMAIL_TO = [
|
|||||||
if item.strip()
|
if item.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Spark Receipt Email — payslip and receipt PDFs are sent here for accounting
|
||||||
|
# Set SPARK_RECEIPT_EMAIL in your .env file (e.g. receipts@spark.co.za)
|
||||||
|
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "")
|
||||||
|
|
||||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||||
if EMAIL_USE_SSL:
|
if EMAIL_USE_SSL:
|
||||||
EMAIL_USE_TLS = False
|
EMAIL_USE_TLS = False
|
||||||
|
|||||||
88
core/templates/core/email/payslip_email.html
Normal file
88
core/templates/core/email/payslip_email.html
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
/* === EMAIL STYLES ===
|
||||||
|
Email clients have limited CSS support, so we use inline-friendly styles.
|
||||||
|
Worker name is the dominant element — no prominent Fox Fitt branding
|
||||||
|
(Spark reads the vendor name from the document). */
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px; }
|
||||||
|
.header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
|
||||||
|
.beneficiary-name { font-size: 24px; font-weight: bold; text-transform: uppercase; color: #000; }
|
||||||
|
.sub-header { font-size: 14px; color: #666; margin-bottom: 5px; }
|
||||||
|
.title { font-size: 18px; font-weight: bold; color: #666; }
|
||||||
|
.meta { margin-bottom: 20px; background-color: #f8f9fa; padding: 10px; border-radius: 4px; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||||
|
.items-table th, .items-table td { border-bottom: 1px solid #eee; padding: 8px; text-align: left; }
|
||||||
|
.items-table th { background-color: #f8f9fa; }
|
||||||
|
.totals { text-align: right; margin-top: 20px; border-top: 2px solid #333; padding-top: 10px; }
|
||||||
|
.total-row { font-size: 20px; font-weight: bold; color: #000; }
|
||||||
|
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
|
||||||
|
.positive { color: green; }
|
||||||
|
.negative { color: red; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header: worker name dominant -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
|
||||||
|
<div class="beneficiary-name">{{ record.worker.name }}</div>
|
||||||
|
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beneficiary details -->
|
||||||
|
<div class="meta">
|
||||||
|
<strong>Beneficiary:</strong> {{ record.worker.name }}<br>
|
||||||
|
<strong>ID Number:</strong> {{ record.worker.id_number }}<br>
|
||||||
|
<strong>Date:</strong> {{ record.date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line items table -->
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="text-align: right;">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if is_advance %}
|
||||||
|
<tr>
|
||||||
|
<td>Advance Payment: {{ advance_description }}</td>
|
||||||
|
<td style="text-align: right;">R {{ advance_amount|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<!-- Base pay line -->
|
||||||
|
<tr>
|
||||||
|
<td>Base Pay ({{ logs_count }} days worked)</td>
|
||||||
|
<td style="text-align: right;">R {{ logs_amount|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- All adjustments (bonuses add, deductions subtract) -->
|
||||||
|
{% for adj in adjustments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ adj.get_type_display }}: {{ adj.description }}</td>
|
||||||
|
<td style="text-align: right;" class="{% if adj.type in deductive_types %}negative{% else %}positive{% endif %}">
|
||||||
|
{% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Net pay total -->
|
||||||
|
<div class="totals">
|
||||||
|
<p class="total-row">Net Pay: R {{ record.amount_paid|floatformat:2 }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer — minimal branding -->
|
||||||
|
<div class="footer">
|
||||||
|
<p>Payer: Fox Fitt | Generated for {{ record.worker.name }}</p>
|
||||||
|
<p>Date Generated: {% now "Y-m-d H:i" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -252,7 +252,8 @@
|
|||||||
<th scope="col">Worker</th>
|
<th scope="col">Worker</th>
|
||||||
<th scope="col">Amount Paid</th>
|
<th scope="col">Amount Paid</th>
|
||||||
<th scope="col">Work Logs</th>
|
<th scope="col">Work Logs</th>
|
||||||
<th scope="col" class="pe-4">Adjustments</th>
|
<th scope="col">Adjustments</th>
|
||||||
|
<th scope="col" class="pe-4 text-end">Payslip</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -264,7 +265,7 @@
|
|||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
|
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
|
||||||
</td>
|
</td>
|
||||||
<td class="pe-4 align-middle">
|
<td class="align-middle">
|
||||||
{% for adj in record.adjustments.all %}
|
{% for adj in record.adjustments.all %}
|
||||||
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}bg-success{% else %}bg-danger{% endif %} me-1">
|
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}bg-success{% else %}bg-danger{% endif %} me-1">
|
||||||
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
|
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
|
||||||
@ -273,10 +274,15 @@
|
|||||||
<span class="text-muted">-</span>
|
<span class="text-muted">-</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="pe-4 align-middle text-end">
|
||||||
|
<a href="{% url 'payslip_detail' record.id %}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fas fa-file-alt me-1"></i> View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center py-5 text-muted">
|
<td colspan="6" class="text-center py-5 text-muted">
|
||||||
<i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i>
|
<i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i>
|
||||||
No payment history yet.
|
No payment history yet.
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
165
core/templates/core/payslip.html
Normal file
165
core/templates/core/payslip.html
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Payslip #{{ record.id }} | Fox Fitt{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- === PAYSLIP DETAIL PAGE ===
|
||||||
|
Shows a completed payment with work logs, adjustments, and totals.
|
||||||
|
Reached from the Payment History tab on the payroll dashboard.
|
||||||
|
Has a Print button that uses the browser's native print dialog. -->
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<!-- Action buttons (hidden when printing) -->
|
||||||
|
<div class="d-print-none mb-4 d-grid gap-2 d-md-flex">
|
||||||
|
<a href="{% url 'payroll_dashboard' %}?status=paid" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> Back to Payment History
|
||||||
|
</a>
|
||||||
|
<button onclick="window.print()" class="btn btn-accent">
|
||||||
|
<i class="fas fa-print me-1"></i> Print Payslip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payslip card -->
|
||||||
|
<div class="card border-0 shadow-sm" id="payslip-card">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
|
||||||
|
<!-- === HEADER — worker name is the dominant element === -->
|
||||||
|
<div class="row mb-5 border-bottom pb-4 align-items-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold small mb-1">Payment To Beneficiary:</h6>
|
||||||
|
<h2 class="fw-bold text-dark mb-0 text-uppercase">{{ record.worker.name }}</h2>
|
||||||
|
<p class="text-muted small mb-0">Payslip No. #{{ record.id|stringformat:"06d" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end mt-3 mt-md-0">
|
||||||
|
<h3 class="fw-bold text-uppercase text-secondary mb-1">Payslip</h3>
|
||||||
|
<div class="fw-bold">{{ record.date|date:"F j, Y" }}</div>
|
||||||
|
<div class="text-muted small">Payer: Fox Fitt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === WORKER DETAILS + NET PAY === -->
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold small mb-3">Beneficiary Details:</h6>
|
||||||
|
<h4 class="fw-bold">{{ record.worker.name }}</h4>
|
||||||
|
<p class="mb-0">ID Number: <strong>{{ record.worker.id_number }}</strong></p>
|
||||||
|
<p class="mb-0">Phone: {{ record.worker.phone_number|default:"—" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end mt-4 mt-md-0">
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold small mb-3">Net Payable Amount:</h6>
|
||||||
|
<div class="display-6 fw-bold text-dark">R {{ record.amount_paid|floatformat:2 }}</div>
|
||||||
|
<p class="text-success small fw-bold mt-2">
|
||||||
|
<i class="fas fa-check-circle me-1"></i> PAID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === WORK LOG TABLE — each day worked === -->
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.date|date:"M d, Y" }}</td>
|
||||||
|
<td>{{ log.project.name }}</td>
|
||||||
|
<td>{{ log.notes|default:"—"|truncatechars:50 }}</td>
|
||||||
|
<td class="text-end">R {{ record.worker.daily_rate|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">
|
||||||
|
<i class="fas fa-info-circle me-1"></i> No work logs in this period.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="table-light">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
|
||||||
|
<td class="text-end fw-bold">R {{ base_pay|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === ADJUSTMENTS TABLE — bonuses, deductions, overtime, loan repayments === -->
|
||||||
|
{% if adjustments %}
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold small mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</h6>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for adj in adjustments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ adj.date|date:"M d, Y" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ adj.description }}</td>
|
||||||
|
<td class="text-end {% if adj.type in deductive_types %}text-danger{% else %}text-success{% endif %}">
|
||||||
|
{% if adj.type in deductive_types %}
|
||||||
|
- R {{ adj.amount|floatformat:2 }}
|
||||||
|
{% else %}
|
||||||
|
+ R {{ adj.amount|floatformat:2 }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- === GRAND TOTAL SUMMARY === -->
|
||||||
|
<div class="row justify-content-end mt-4">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<table class="table table-sm border-0">
|
||||||
|
<tr>
|
||||||
|
<td class="text-end border-0 text-muted">Base Pay:</td>
|
||||||
|
<td class="text-end border-0" width="140">R {{ base_pay|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if adjustments %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-end border-0 text-muted">Adjustments Net:</td>
|
||||||
|
<td class="text-end border-0">
|
||||||
|
{% if adjustments_net >= 0 %}
|
||||||
|
+ R {{ adjustments_net|floatformat:2 }}
|
||||||
|
{% else %}
|
||||||
|
- R {{ adjustments_net_abs|floatformat:2 }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="border-top border-dark">
|
||||||
|
<td class="text-end border-0 fw-bold fs-5">Net Payable:</td>
|
||||||
|
<td class="text-end border-0 fw-bold fs-5">R {{ record.amount_paid|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === FOOTER === -->
|
||||||
|
<div class="text-center text-muted small mt-5 pt-4 border-top">
|
||||||
|
<p>This is a computer-generated document and does not require a signature.</p>
|
||||||
|
<p>Payer: Fox Fitt © 2026</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
165
core/templates/core/pdf/payslip_pdf.html
Normal file
165
core/templates/core/pdf/payslip_pdf.html
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
/* === PAGE SETUP === */
|
||||||
|
/* A4 portrait with 2cm margins and a footer frame at the bottom */
|
||||||
|
@page {
|
||||||
|
size: a4 portrait;
|
||||||
|
margin: 2cm;
|
||||||
|
@frame footer_frame {
|
||||||
|
-pdf-frame-content: footerContent;
|
||||||
|
bottom: 1cm;
|
||||||
|
margin-left: 1cm;
|
||||||
|
margin-right: 1cm;
|
||||||
|
height: 1cm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BODY STYLES === */
|
||||||
|
body {
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 12pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HEADER — worker name is the dominant element === */
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.beneficiary-name {
|
||||||
|
font-size: 24pt;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.sub-header {
|
||||||
|
font-size: 12pt;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === META BOX — beneficiary details === */
|
||||||
|
.meta {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === ITEMS TABLE — base pay + adjustments === */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.items-table th {
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
background-color: #eee;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.items-table td {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TOTALS === */
|
||||||
|
.totals {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 2px solid #333;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.total-row {
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FOOTER — small print at bottom of page === */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Helpers */
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.positive { color: green; }
|
||||||
|
.negative { color: red; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header: worker name is the biggest element (per CLAUDE.md rule) -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
|
||||||
|
<div class="beneficiary-name">{{ record.worker.name }}</div>
|
||||||
|
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beneficiary details box -->
|
||||||
|
<div class="meta">
|
||||||
|
<strong>Beneficiary:</strong> {{ record.worker.name }}<br>
|
||||||
|
<strong>ID Number:</strong> {{ record.worker.id_number }}<br>
|
||||||
|
<strong>Date:</strong> {{ record.date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line items: base pay + all adjustments -->
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-right">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if is_advance %}
|
||||||
|
<!-- Advance Payment — single line item -->
|
||||||
|
<tr>
|
||||||
|
<td>Advance Payment: {{ advance_description }}</td>
|
||||||
|
<td class="text-right">R {{ advance_amount|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<!-- Base Pay — number of days worked × day rate -->
|
||||||
|
<tr>
|
||||||
|
<td>Base Pay ({{ logs_count }} days worked)</td>
|
||||||
|
<td class="text-right">R {{ logs_amount|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- All payroll adjustments (bonuses, deductions, overtime, loan repayments) -->
|
||||||
|
{% for adj in adjustments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ adj.get_type_display }}: {{ adj.description }}</td>
|
||||||
|
<td class="text-right {% if adj.type in deductive_types %}negative{% else %}positive{% endif %}">
|
||||||
|
{% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Net pay total -->
|
||||||
|
<div class="totals">
|
||||||
|
<p class="total-row">Net Pay: R {{ record.amount_paid|floatformat:2 }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer frame (positioned at bottom of page by xhtml2pdf) -->
|
||||||
|
<div id="footerContent" class="footer">
|
||||||
|
Payer: Fox Fitt | Generated for {{ record.worker.name }} | {% now "Y-m-d H:i" %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -43,6 +43,9 @@ urlpatterns = [
|
|||||||
# Preview a worker's payslip (AJAX — returns JSON)
|
# Preview a worker's payslip (AJAX — returns JSON)
|
||||||
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
|
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
|
||||||
|
|
||||||
|
# View a completed payslip (print-friendly page)
|
||||||
|
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),
|
||||||
|
|
||||||
# === TEMPORARY: Import production data from browser ===
|
# === TEMPORARY: Import production data from browser ===
|
||||||
# Visit /import-data/ once to populate the database. Remove after use.
|
# Visit /import-data/ once to populate the database. Remove after use.
|
||||||
path('import-data/', views.import_data, name='import_data'),
|
path('import-data/', views.import_data, name='import_data'),
|
||||||
|
|||||||
34
core/utils.py
Normal file
34
core/utils.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# === PDF GENERATION ===
|
||||||
|
# Converts a Django HTML template into a PDF file using xhtml2pdf.
|
||||||
|
# Used for payslip and receipt PDF attachments sent via email.
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from django.template.loader import get_template
|
||||||
|
from xhtml2pdf import pisa
|
||||||
|
|
||||||
|
|
||||||
|
def render_to_pdf(template_src, context_dict=None):
|
||||||
|
"""
|
||||||
|
Render a Django template to PDF bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_src: Path to the template (e.g. 'core/pdf/payslip_pdf.html')
|
||||||
|
context_dict: Template context variables
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF content as bytes, or None if there was an error.
|
||||||
|
"""
|
||||||
|
if context_dict is None:
|
||||||
|
context_dict = {}
|
||||||
|
|
||||||
|
# Load and render the HTML template
|
||||||
|
template = get_template(template_src)
|
||||||
|
html = template.render(context_dict)
|
||||||
|
|
||||||
|
# Convert HTML to PDF
|
||||||
|
result = BytesIO()
|
||||||
|
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
|
||||||
|
|
||||||
|
if not pdf.err:
|
||||||
|
return result.getvalue()
|
||||||
|
return None
|
||||||
112
core/views.py
112
core/views.py
@ -15,9 +15,14 @@ from django.db.models.functions import TruncMonth
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment
|
from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment
|
||||||
from .forms import AttendanceLogForm, PayrollAdjustmentForm
|
from .forms import AttendanceLogForm, PayrollAdjustmentForm
|
||||||
|
from .utils import render_to_pdf
|
||||||
|
|
||||||
|
|
||||||
# === PAYROLL CONSTANTS ===
|
# === PAYROLL CONSTANTS ===
|
||||||
@ -779,11 +784,68 @@ def process_payment(request, worker_id):
|
|||||||
adj.loan.active = False
|
adj.loan.active = False
|
||||||
adj.loan.save()
|
adj.loan.save()
|
||||||
|
|
||||||
messages.success(
|
# =========================================================================
|
||||||
request,
|
# EMAIL PAYSLIP (outside the transaction — if email fails, payment is
|
||||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
# still saved. We don't want a network error to roll back a real payment.)
|
||||||
f'{log_count} work log(s) marked as paid.'
|
# =========================================================================
|
||||||
)
|
subject = f"Payslip for {worker.name} - {payroll_record.date}"
|
||||||
|
|
||||||
|
# Context for both the HTML email body and the PDF attachment
|
||||||
|
email_context = {
|
||||||
|
'record': payroll_record,
|
||||||
|
'logs_count': log_count,
|
||||||
|
'logs_amount': logs_amount,
|
||||||
|
'adjustments': payroll_record.adjustments.all(),
|
||||||
|
'deductive_types': DEDUCTIVE_TYPES,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Render HTML email body
|
||||||
|
html_message = render_to_string('core/email/payslip_email.html', email_context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
# 2. Render PDF attachment
|
||||||
|
pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', email_context)
|
||||||
|
|
||||||
|
# 3. Send email with PDF attached
|
||||||
|
recipient = getattr(settings, 'SPARK_RECEIPT_EMAIL', None)
|
||||||
|
if recipient:
|
||||||
|
try:
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject,
|
||||||
|
plain_message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[recipient],
|
||||||
|
)
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
|
||||||
|
if pdf_content:
|
||||||
|
email.attach(
|
||||||
|
f"Payslip_{worker.id}_{payroll_record.date}.pdf",
|
||||||
|
pdf_content,
|
||||||
|
'application/pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
email.send()
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||||||
|
f'Payslip emailed successfully.'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Payment is saved — just warn that email failed
|
||||||
|
messages.warning(
|
||||||
|
request,
|
||||||
|
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
|
||||||
|
f'but email delivery failed: {str(e)}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No SPARK_RECEIPT_EMAIL configured — just show success
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||||||
|
f'{log_count} work log(s) marked as paid.'
|
||||||
|
)
|
||||||
|
|
||||||
return redirect('payroll_dashboard')
|
return redirect('payroll_dashboard')
|
||||||
|
|
||||||
|
|
||||||
@ -1101,6 +1163,46 @@ def preview_payslip(request, worker_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# === PAYSLIP DETAIL ===
|
||||||
|
# Shows a completed payment (PayrollRecord) as a printable payslip page.
|
||||||
|
# Displays: worker details, work log table, adjustments table, totals.
|
||||||
|
# Reached from the "Payment History" tab on the payroll dashboard.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def payslip_detail(request, pk):
|
||||||
|
"""Show a completed payslip with work logs, adjustments, and totals."""
|
||||||
|
if not is_admin(request.user):
|
||||||
|
return redirect('payroll_dashboard')
|
||||||
|
|
||||||
|
record = get_object_or_404(PayrollRecord, pk=pk)
|
||||||
|
|
||||||
|
# Get the work logs included in this payment
|
||||||
|
logs = record.work_logs.select_related('project').order_by('date')
|
||||||
|
|
||||||
|
# Get the adjustments linked to this payment
|
||||||
|
adjustments = record.adjustments.all().order_by('type')
|
||||||
|
|
||||||
|
# Calculate base pay from logs
|
||||||
|
# Each log = 1 day of work at the worker's daily rate
|
||||||
|
base_pay = record.worker.daily_rate * logs.count()
|
||||||
|
|
||||||
|
# Calculate net adjustment amount (additive minus deductive)
|
||||||
|
adjustments_net = record.amount_paid - base_pay
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'record': record,
|
||||||
|
'logs': logs,
|
||||||
|
'adjustments': adjustments,
|
||||||
|
'base_pay': base_pay,
|
||||||
|
'adjustments_net': adjustments_net,
|
||||||
|
'adjustments_net_abs': abs(adjustments_net),
|
||||||
|
'deductive_types': DEDUCTIVE_TYPES,
|
||||||
|
}
|
||||||
|
return render(request, 'core/payslip.html', context)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# === IMPORT DATA (TEMPORARY) ===
|
# === IMPORT DATA (TEMPORARY) ===
|
||||||
# Runs the import_production_data command from the browser.
|
# Runs the import_production_data command from the browser.
|
||||||
|
|||||||
23
docs/plans/2026-02-22-payslip-feature-design.md
Normal file
23
docs/plans/2026-02-22-payslip-feature-design.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Payslip Feature Design — 22 Feb 2026
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Complete the payment workflow: when "Pay" is clicked, generate a PDF payslip and email it to Spark. Also add a payslip detail page for viewing/printing past payslips.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
1. `core/utils.py` — render_to_pdf() xhtml2pdf wrapper
|
||||||
|
2. `core/templates/core/pdf/payslip_pdf.html` — A4 PDF template
|
||||||
|
3. `core/templates/core/email/payslip_email.html` — HTML email body
|
||||||
|
4. `core/templates/core/payslip.html` — Browser payslip detail page
|
||||||
|
5. `core/views.py` — payslip_detail view + email in process_payment
|
||||||
|
6. `core/urls.py` — payroll/payslip/<pk>/
|
||||||
|
7. `config/settings.py` — SPARK_RECEIPT_EMAIL + DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
process_payment → atomic(create record, link logs/adjs, update loans) → email PDF to Spark → redirect
|
||||||
|
|
||||||
|
## Key: V2 → V5 field mapping
|
||||||
|
- record.amount → record.amount_paid
|
||||||
|
- worker.id_no → worker.id_number
|
||||||
|
- worker.phone_no → worker.phone_number
|
||||||
|
- loan.balance → loan.remaining_balance
|
||||||
|
- loan.amount → loan.principal_amount
|
||||||
Loading…
x
Reference in New Issue
Block a user