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()
|
||||
]
|
||||
|
||||
# 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
|
||||
if EMAIL_USE_SSL:
|
||||
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">Amount Paid</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -264,7 +265,7 @@
|
||||
<td class="align-middle">
|
||||
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
|
||||
</td>
|
||||
<td class="pe-4 align-middle">
|
||||
<td class="align-middle">
|
||||
{% 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">
|
||||
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
|
||||
@ -273,10 +274,15 @@
|
||||
<span class="text-muted">-</span>
|
||||
{% endfor %}
|
||||
</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>
|
||||
{% empty %}
|
||||
<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>
|
||||
No payment history yet.
|
||||
</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)
|
||||
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 ===
|
||||
# Visit /import-data/ once to populate the database. Remove after use.
|
||||
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.auth.decorators import login_required
|
||||
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 .forms import AttendanceLogForm, PayrollAdjustmentForm
|
||||
from .utils import render_to_pdf
|
||||
|
||||
|
||||
# === PAYROLL CONSTANTS ===
|
||||
@ -779,11 +784,68 @@ def process_payment(request, worker_id):
|
||||
adj.loan.active = False
|
||||
adj.loan.save()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||||
f'{log_count} work log(s) marked as paid.'
|
||||
)
|
||||
# =========================================================================
|
||||
# EMAIL PAYSLIP (outside the transaction — if email fails, payment is
|
||||
# still saved. We don't want a network error to roll back a real payment.)
|
||||
# =========================================================================
|
||||
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')
|
||||
|
||||
|
||||
@ -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) ===
|
||||
# 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