diff --git a/payroll/admin.py b/payroll/admin.py index 40b938967..2cf963052 100644 --- a/payroll/admin.py +++ b/payroll/admin.py @@ -11,6 +11,7 @@ from payroll.models.models import ( Payslip, WorkRecord, LoanAccount, + Reimbursement ) from payroll.models.tax_models import ( PayrollSettings, @@ -27,3 +28,4 @@ admin.site.register(Deduction) admin.site.register(Payslip) admin.site.register(PayrollSettings) admin.site.register(LoanAccount) +admin.site.register(Reimbursement) diff --git a/payroll/forms/component_forms.py b/payroll/forms/component_forms.py index 468c9e44e..0de69cc7c 100644 --- a/payroll/forms/component_forms.py +++ b/payroll/forms/component_forms.py @@ -2,20 +2,30 @@ These forms provide a convenient way to handle data input, validation, and customization of form fields and widgets for the corresponding models in the payroll management system. """ +from typing import Any import uuid import datetime from django import forms from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string +from base import thread_local_middleware from horilla_widgets.forms import HorillaForm from horilla_widgets.widgets.horilla_multi_select_field import HorillaMultiSelectField from horilla_widgets.widgets.select_widgets import HorillaMultiSelectWidget from base.forms import Form, ModelForm from employee.models import Employee from employee.filters import EmployeeFilter +from leave.models import AvailableLeave, LeaveType from payroll.models import tax_models as models from payroll.widgets import component_widgets as widget -from payroll.models.models import Allowance, Contract, LoanAccount, Payslip +from payroll.models.models import ( + Allowance, + Contract, + LoanAccount, + Payslip, + Reimbursement, + ReimbursementMultipleAttachment, +) import payroll.models.models from base.methods import reload_queryset @@ -378,7 +388,11 @@ class LoanAccountForm(ModelForm): if self.instance.pk: self.verbose_name = self.instance.title fields_to_exclude = ["employee_id", "installment_start_date"] - if Payslip.objects.filter(installment_ids__in=list(self.instance.deduction_ids.values_list("id",flat=True))).exists(): + if Payslip.objects.filter( + installment_ids__in=list( + self.instance.deduction_ids.values_list("id", flat=True) + ) + ).exists(): fields_to_exclude = fields_to_exclude + ["loan_amount", "installments"] self.initial["provided_date"] = str(self.instance.provided_date) for field in fields_to_exclude: @@ -388,14 +402,133 @@ class LoanAccountForm(ModelForm): class AssetFineForm(LoanAccountForm): verbose_name = "Asset Fine" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['loan_amount'].label = 'Fine Amount' - fields_to_exclude = ["employee_id", "provided_date","type"] + self.fields["loan_amount"].label = "Fine Amount" + fields_to_exclude = ["employee_id", "provided_date", "type"] for field in fields_to_exclude: - if field in self.fields: - del self.fields[field] - + if field in self.fields: + del self.fields[field] - pass - \ No newline at end of file + + +class MultipleFileInput(forms.ClearableFileInput): + allow_multiple_selected = True + + +class MultipleFileField(forms.FileField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", MultipleFileInput()) + super().__init__(*args, **kwargs) + + def clean(self, data, initial=None): + single_file_clean = super().clean + if isinstance(data, (list, tuple)): + result = [single_file_clean(d, initial) for d in data] + else: + result = [single_file_clean(data, initial)] + return result[0] + + +class ReimbursementForm(ModelForm): + """ + ReimbursementForm + """ + + verbose_name = "Reimbursement / Encashment" + + class Meta: + model = Reimbursement + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + exclude_fields = [] + if not self.instance.pk: + self.initial["allowance_on"] = str(datetime.date.today()) + + request = getattr(thread_local_middleware._thread_locals, "request", None) + if request: + employee = ( + request.user.employee_get + if self.instance.pk is None + else self.instance.employee_id + ) + self.initial["employee_id"] = employee.id + assigned_leaves = LeaveType.objects.filter( + employee_available_leave__employee_id=employee, + employee_available_leave__total_leave_days__gte=1, + ) + self.assigned_leaves = AvailableLeave.objects.filter( + leave_type_id__in=assigned_leaves, employee_id=employee + ) + self.fields["leave_type_id"].queryset = assigned_leaves + self.fields["leave_type_id"].empty_label = None + self.fields["employee_id"].empty_label = None + + type_attr = self.fields["type"].widget.attrs + type_attr["onchange"] = "toggleReimbursmentType($(this))" + self.fields["type"].widget.attrs.update(type_attr) + + employee_attr = self.fields["employee_id"].widget.attrs + employee_attr["onchange"] = "getAssignedLeave($(this).val())" + self.fields["employee_id"].widget.attrs.update(employee_attr) + + self.fields["allowance_on"].widget = forms.DateInput( + attrs={"type": "date", "class": "oh-input w-100"} + ) + self.fields["attachment"] = MultipleFileField(label="Attachements") + + # deleting fields based on type + type = None + if self.data and not self.instance.pk: + type = self.data["type"] + elif self.instance is not None: + type = self.instance.type + + if not request.user.has_perm("payroll.add_reimbursement"): + exclude_fields.append("employee_id") + + if type == "reimbursement" and self.instance.pk: + exclude_fields = exclude_fields + [ + "leave_type_id", + "cfd_to_encash", + "ad_to_encash", + ] + elif self.instance.pk or self.data.get("type") == "leave_encashment": + exclude_fields = exclude_fields + [ + "attachment", + "amount", + ] + if self.instance.pk: + exclude_fields = exclude_fields + ["type", "employee_id"] + + for field in exclude_fields: + if field in self.fields: + del self.fields[field] + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("common_form.html", context) + return table_html + + def save(self, commit: bool = ...) -> Any: + attachemnt = [] + multiple_attachment_ids = [] + attachemnts = None + if self.files.getlist("attachment"): + attachemnts = self.files.getlist("attachment") + self.instance.attachemnt = attachemnts[0] + multiple_attachment_ids = [] + for attachemnt in attachemnts: + file_instance = ReimbursementMultipleAttachment() + file_instance.attachment = attachemnt + file_instance.save() + multiple_attachment_ids.append(file_instance.pk) + instance = super().save(commit) + instance.other_attachments.add(*multiple_attachment_ids) + return instance, attachemnts diff --git a/payroll/models/models.py b/payroll/models/models.py index 6cba6b4e6..602068eae 100644 --- a/payroll/models/models.py +++ b/payroll/models/models.py @@ -2,8 +2,10 @@ models.py Used to register models """ +from collections.abc import Iterable from datetime import date, datetime, timedelta import threading +from typing import Any from django import forms from django.db import models from django.dispatch import receiver @@ -14,6 +16,7 @@ from django.utils.translation import gettext_lazy as _ from django.db.models.signals import pre_save, pre_delete from django.http import QueryDict from asset.models import Asset +from base import thread_local_middleware from employee.models import EmployeeWorkInformation from employee.models import Employee, Department, JobPosition from base.models import Company, EmployeeShift, WorkType, JobRole @@ -25,7 +28,7 @@ from attendance.models import ( Attendance, strtime_seconds, ) -from leave.models import LeaveRequest +from leave.models import LeaveRequest, LeaveType # Create your models here. @@ -1360,7 +1363,7 @@ class LoanAccount(models.Model): @receiver(post_save, sender=LoanAccount) def create_installments(sender, instance, created, **kwargs): """ - This is post save method, used to create initial stage for the recruitment + Post save metod for loan account """ installments = [] if created and instance.asset_id is None and instance.type != "fine": @@ -1405,3 +1408,152 @@ def create_installments(sender, instance, created, **kwargs): installment.save() installments.append(installment) instance.deduction_ids.set(installments) + + +class ReimbursementMultipleAttachment(models.Model): + """ + ReimbursementMultipleAttachement Model + """ + + attachment = models.FileField(upload_to="payroll/reimbursements") + + +class Reimbursement(models.Model): + """ + Reimbursement Model + """ + + reimbursement_types = [ + ("reimbursement", "Reimbursement"), + ("leave_encashment", "Leave Encashment"), + ] + status_types = [ + ("requested", "Requested"), + ("approved", "Approved"), + ("canceled", "Canceled"), + ] + title = models.CharField(max_length=50) + type = models.CharField( + choices=reimbursement_types, max_length=16, default="reimbursement" + ) + employee_id = models.ForeignKey( + Employee, on_delete=models.PROTECT, verbose_name="Employee" + ) + allowance_on = models.DateField() + attachment = models.FileField(upload_to="payroll/reimbursements", null=True) + other_attachments = models.ManyToManyField( + ReimbursementMultipleAttachment, blank=True, editable=False + ) + leave_type_id = models.ForeignKey( + LeaveType, on_delete=models.PROTECT, null=True, verbose_name="Leave type" + ) + ad_to_encash = models.FloatField( + default=0, help_text="Available Days to encash", verbose_name="Available days" + ) + cfd_to_encash = models.FloatField( + default=0, + help_text="Carry Forward Days to encash", + verbose_name="Carry forward days", + ) + amount = models.FloatField(default=0) + status = models.CharField( + max_length=10, choices=status_types, default="requested", editable=False + ) + approved_by = models.ForeignKey( + Employee, + on_delete=models.SET_NULL, + null=True, + related_name="approved_by", + editable=False, + ) + description = models.TextField(null=True) + allowance_id = models.ForeignKey( + Allowance, on_delete=models.SET_NULL, null=True, editable=False + ) + created_at = models.DateTimeField(auto_now_add=True) + is_active=models.BooleanField(default=True,editable=False) + + def save(self, *args, **kwargs) -> None: + request = getattr(thread_local_middleware._thread_locals, "request", None) + + # Setting the created use if the used dont have the permission + has_perm = request.user.has_perm("payroll.add_reimbursement") + if not has_perm: + self.employee_id = request.user.employee_get + if self.type == "reimbursement" and self.attachment is None: + raise ValidationError({"attachment": "This field is required"}) + elif self.type == "leave_encashment" and self.leave_type_id is None: + raise ValidationError({"leave_type_id": "This field is required"}) + self.cfd_to_encash = max((round(self.cfd_to_encash * 2) / 2), 0) + self.ad_to_encash = max((round(self.ad_to_encash * 2) / 2), 0) + assigned_leave = self.leave_type_id.employee_available_leave.filter( + employee_id=self.employee_id + ).first() + if self.status != "approved" or self.allowance_id is None: + super().save(*args, **kwargs) + if self.status == "approved" and self.allowance_id is None: + if self.type == "reimbursement": + proceed = True + else: + proceed = False + if assigned_leave: + available_days = assigned_leave.available_days + carryforward_days = assigned_leave.carryforward_days + if ( + available_days >= self.ad_to_encash + and carryforward_days >= self.cfd_to_encash + ): + proceed = True + assigned_leave.available_days = ( + available_days - self.ad_to_encash + ) + assigned_leave.carryforward_days = ( + carryforward_days - self.cfd_to_encash + ) + assigned_leave.save() + else: + request = getattr( + thread_local_middleware._thread_locals, "request", None + ) + if request: + messages.info( + request, + "The employee don't have that much leaves to encash in CFD / Available days", + ) + + # if self.ad + + if proceed: + reimbursement = Allowance() + reimbursement.one_time_date = self.allowance_on + reimbursement.title = self.title + reimbursement.only_show_under_employee = True + reimbursement.include_active_employees = False + reimbursement.amount = self.amount + reimbursement.save() + reimbursement.include_active_employees = False + reimbursement.specific_employees.add(self.employee_id) + reimbursement.save() + self.allowance_id = reimbursement + if request: + self.approved_by = request.user.employee_get + else: + self.status = "requested" + super().save(*args, **kwargs) + elif self.status == "canceled" and self.allowance_id is not None: + cfd_days = self.cfd_to_encash + available_days = self.ad_to_encash + if assigned_leave: + assigned_leave.available_days = ( + assigned_leave.available_days + available_days + ) + assigned_leave.carryforward_days = ( + assigned_leave.carryforward_days + cfd_days + ) + assigned_leave.save() + self.allowance_id.delete() + + def delete(self, *args, **kwargs): + if self.allowance_id: + self.allowance_id.delete() + return super().delete(*args, **kwargs) diff --git a/payroll/templates/payroll/reimbursement/attachments.html b/payroll/templates/payroll/reimbursement/attachments.html new file mode 100644 index 000000000..1f38aede0 --- /dev/null +++ b/payroll/templates/payroll/reimbursement/attachments.html @@ -0,0 +1,16 @@ +{% load i18n %} +

{% trans 'Attachments' %}

+ +{% for attachment in reimbursement.other_attachments.all %} +
+
+ + +
+ + +
+{% endfor %} +
+ +
diff --git a/payroll/templates/payroll/reimbursement/filter.html b/payroll/templates/payroll/reimbursement/filter.html new file mode 100644 index 000000000..79cc8e7f9 --- /dev/null +++ b/payroll/templates/payroll/reimbursement/filter.html @@ -0,0 +1,55 @@ +{% load i18n %} +
+
+ +
+
+
+
{% trans "Reimbursement" %}
+
+
+
+
+ + {{f.form.employee_id}} +
+
+ + {{f.form.status}} +
+
+ + {{f.form.employee_id__employee_work_info__reporting_manager_id}} +
+
+
+
+ + {{f.form.employee_id__employee_work_info__department_id}} +
+
+ + {{f.form.type}} +
+
+ + {{f.form.employee_id__employee_work_info__job_position_id}} +
+
+
+
+
+
+ +
+
+ +
\ No newline at end of file diff --git a/payroll/templates/payroll/reimbursement/form.html b/payroll/templates/payroll/reimbursement/form.html new file mode 100644 index 000000000..02d214321 --- /dev/null +++ b/payroll/templates/payroll/reimbursement/form.html @@ -0,0 +1,40 @@ +{% load i18n %} +
+ {{form.as_p}} +
+ + + + + + + + + + {% for available_leave in form.assigned_leaves %} + + + + + + {% endfor %} + + + \ No newline at end of file diff --git a/payroll/templates/payroll/reimbursement/nav.html b/payroll/templates/payroll/reimbursement/nav.html new file mode 100644 index 000000000..406a076c0 --- /dev/null +++ b/payroll/templates/payroll/reimbursement/nav.html @@ -0,0 +1,32 @@ +{% load i18n %} +
+
+

{% trans 'Reimbursements' %}

+
+ +
+
+
+ + +
+
    +
  • + +
  • +
  • + +
  • +
+ {% include 'payroll/reimbursement/filter.html' %} + +
+
+
diff --git a/payroll/templates/payroll/reimbursement/request_cards.html b/payroll/templates/payroll/reimbursement/request_cards.html new file mode 100644 index 000000000..6da9d6a80 --- /dev/null +++ b/payroll/templates/payroll/reimbursement/request_cards.html @@ -0,0 +1,174 @@ +{% load i18n %} +{% include "filter_tags.html" %} +
+ + + {% trans "Canceled" %} + + + + {% trans "Requested" %} + + + + {% trans "Approved" %} + + + + {% trans "Encashment" %} + + + + {% trans "Reimbursement" %} + +
+
+{% for req in requests %} +
+
+
+
{{ req.get_type_display }}
+
{{ req.get_status_display }}
+
+
+ {% if req.status != "approved" %} + + {% endif %} + {% if perms.payroll.delete_reimbursement %} + + {% endif %} +
+
+
+
+
+ +
+
+ {{ req.employee_id }} + + {{req.employee_id.get_department }} / {{ req.employee_id.get_job_position }} +
+
+
+

{{ req.title }}

+

{{ req.description }}.

+ + {% trans 'Allowance on' %} {{ req.allowance_on }}. + {% if req.type == 'reimbursement' %} + {% trans 'View Attachments' %} + {% endif %} + + {% if perms.payroll.change_reimbursement %} +
+ + + {% if req.type == 'reimbursement' %} +

+ +

+ {% else %} +

+ + {% trans 'Requsted for total' %} {{ req.ad_to_encash|add:req.cfd_to_encash }} + {% trans 'days' %} + {% trans 'days to encash.' %} + + +

+ {% endif %} +
+ + {% if req.status != "approved" %} + + {% else %} + + {% endif %} +
+ +
+ {% endif %} + +
+{% endfor %} +
+ +
+
+ {% trans 'Page' %} {{ requests.number }} {% trans 'of' %} {{ requests.paginator.num_pages }}. + + +
+
+ + \ No newline at end of file diff --git a/payroll/templates/payroll/reimbursement/view_reimbursement.html b/payroll/templates/payroll/reimbursement/view_reimbursement.html new file mode 100644 index 000000000..47467148d --- /dev/null +++ b/payroll/templates/payroll/reimbursement/view_reimbursement.html @@ -0,0 +1,91 @@ +{% extends 'index.html' %} +{% block content %} + {% include 'payroll/reimbursement/nav.html' %} + +
+ {% include 'payroll/reimbursement/request_cards.html' %} +
+ + + +{% endblock %} diff --git a/payroll/urls/component_urls.py b/payroll/urls/component_urls.py index a181ba979..ed25a29e6 100644 --- a/payroll/urls/component_urls.py +++ b/payroll/urls/component_urls.py @@ -86,4 +86,40 @@ urlpatterns = [ component_views.asset_fine, name="asset-fine", ), + path( + "view-reimbursement", + component_views.view_reimbursement, + name="view-reimbursement", + ), + path( + "create-reimbursement", + component_views.create_reimbursement, + name="create-reimbursement", + ), + path( + "search-reimbursement", + component_views.search_reimbursement, + name="search-reimbursement", + ), + path( + "get-assigned-leaves/", + component_views.get_assigned_leaves, + name="get-assigned-leaves", + ), + path( + "approve-reimbursements", + component_views.approve_reimbursements, + name="approve-reimbursements", + ), + path( + "delete-reimbursements", + component_views.delete_reimbursements, + name="delete-reimbursement", + ), + path( + "reimbursement-attachements//", + component_views.reimbursement_attachments, + name="reimbursement-attachments", + ), + path("delete-attachments//",component_views.delete_attachments,name="delete-attachments") ]