diff --git a/assets/pasted-20260222-085917-86d59323.jpg b/assets/pasted-20260222-085917-86d59323.jpg new file mode 100644 index 0000000..8187828 Binary files /dev/null and b/assets/pasted-20260222-085917-86d59323.jpg differ diff --git a/assets/pasted-20260222-085955-7f073603.png b/assets/pasted-20260222-085955-7f073603.png new file mode 100644 index 0000000..b58ba1d Binary files /dev/null and b/assets/pasted-20260222-085955-7f073603.png differ diff --git a/assets/vm-shot-2026-02-22T08-59-12-939Z.jpg b/assets/vm-shot-2026-02-22T08-59-12-939Z.jpg new file mode 100644 index 0000000..8187828 Binary files /dev/null and b/assets/vm-shot-2026-02-22T08-59-12-939Z.jpg differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 6db9df8..1bd1920 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 70ae46a..bbb1a9b 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/migrations/0018_payrolladjustment_project_and_more.py b/core/migrations/0018_payrolladjustment_project_and_more.py new file mode 100644 index 0000000..98fc0fc --- /dev/null +++ b/core/migrations/0018_payrolladjustment_project_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2026-02-22 08:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_alter_payrolladjustment_date_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='payrolladjustment', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments_by_project', to='core.project'), + ), + migrations.AlterField( + model_name='payrolladjustment', + name='work_log', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments_by_work_log', to='core.worklog'), + ), + ] diff --git a/core/migrations/__pycache__/0018_payrolladjustment_project_and_more.cpython-311.pyc b/core/migrations/__pycache__/0018_payrolladjustment_project_and_more.cpython-311.pyc new file mode 100644 index 0000000..67e2b69 Binary files /dev/null and b/core/migrations/__pycache__/0018_payrolladjustment_project_and_more.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 75d588b..c80cc0d 100644 --- a/core/models.py +++ b/core/models.py @@ -168,8 +168,8 @@ class PayrollAdjustment(models.Model): worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments') payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments') loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments') - work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments' - project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments' + work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_work_log') + project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_project') amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)") date = models.DateField(default=timezone.now, db_index=True) @@ -232,4 +232,4 @@ class ExpenseLineItem(models.Model): verbose_name_plural = "Expense Line Items" def __str__(self): - return f"{self.product} - {self.amount}" + return f"{self.product} - {self.amount}" \ No newline at end of file diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 6156722..101c581 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -177,7 +177,7 @@ data-adj-description="{{ adj.description }}" data-adj-date="{{ adj.date|date:'Y-m-d' }}" data-adj-worker="{{ item.worker.name }}" - data-adj-project="{% if adj.work_log %}{{ adj.work_log.project_id }}{% endif %}" + data-adj-project="{% if adj.project_id %}{{ adj.project_id }}{% elif adj.work_log %}{{ adj.work_log.project_id }}{% endif %}" title="{% if adj.type == 'ADVANCE' %}Click to delete (cannot edit){% else %}Click to edit{% endif %}"> {{ adj.get_type_display }}: R {{ adj.amount }} {% if adj.type != 'ADVANCE' %}✎{% endif %} diff --git a/core/views.py b/core/views.py index 10e0779..1291607 100644 --- a/core/views.py +++ b/core/views.py @@ -140,15 +140,16 @@ def home(request): if user_is_admin: # Bulk-fetch all pending project-linked adjustments in one query pending_proj_adjs = {} + # Changed filter to look at adj.project or adj.work_log.project for adj in PayrollAdjustment.objects.filter( - work_log__project__isnull=False, + Q(project__isnull=False) | Q(work_log__project__isnull=False), payroll_record__isnull=True - ).select_related('work_log'): - pid = adj.work_log.project_id + ).select_related('work_log', 'project'): + pid = adj.project_id or adj.work_log.project_id pending_proj_adjs.setdefault(pid, []).append(adj) for project in all_projects: - outstanding_cost = 0 + outstanding_cost = Decimal('0.00') # Unpaid WorkLogs (use prefetch cache, check paid_in in Python) for log in project.logs.all(): @@ -394,14 +395,14 @@ def work_log_list(request): logs = logs.filter(paid_in__isnull=True) # --- 2. Fetch Adjustments --- - adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log', 'work_log__project') + adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log', 'work_log__project', 'project') if worker_id: adjustments = adjustments.filter(worker_id=worker_id) if project_id: - # Include only adjustments linked to this project (via work_log) - adjustments = adjustments.filter(work_log__project_id=project_id) + # Include only adjustments linked to this project (via work_log OR direct project field) + adjustments = adjustments.filter(Q(work_log__project_id=project_id) | Q(project_id=project_id)) if team_id: adjustments = adjustments.filter(work_log__team_id=team_id) @@ -439,7 +440,7 @@ def work_log_list(request): # --- 4. Combine and Sort --- user_is_admin = is_admin(request.user) - total_amount = 0 + total_amount = Decimal('0.00') combined_records = [] # Prepare Chart Data (Overtime) - Admin only @@ -493,11 +494,18 @@ def work_log_list(request): if adj.type in DEDUCTIVE_TYPES: amt = -amt + # Determine Project Name + pname = adj.get_type_display() + if adj.project: + pname = f"{adj.get_type_display()} ({adj.project.name})" + elif adj.work_log and adj.work_log.project: + pname = f"{adj.get_type_display()} ({adj.work_log.project.name})" + record = { 'type': 'ADJ', 'date': adj.date, 'obj': adj, - 'project_name': f"{adj.get_type_display()} ({adj.work_log.project.name})" if adj.work_log and getattr(adj.work_log, 'project', None) else f"{adj.get_type_display()}", # Use project column for Type + 'project_name': pname, # Use project column for Type 'team_name': None, 'workers': [adj.worker], 'supervisor': "System", @@ -637,13 +645,13 @@ def export_work_log_csv(request): logs = logs.filter(paid_in__isnull=True) # --- 2. Fetch Adjustments --- - adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log', 'work_log__project') + adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log', 'work_log__project', 'project') if worker_id: adjustments = adjustments.filter(worker_id=worker_id) if project_id: - adjustments = adjustments.filter(work_log__project_id=project_id) + adjustments = adjustments.filter(Q(work_log__project_id=project_id) | Q(project_id=project_id)) if team_id: adjustments = adjustments.filter(work_log__team_id=team_id) @@ -675,7 +683,7 @@ def export_work_log_csv(request): else: workers_str = ", ".join([w.name for w in log.workers.all()]) - amt = 0 + amt = Decimal('0.00') is_paid = False if user_is_admin: if target_worker: @@ -701,9 +709,15 @@ def export_work_log_csv(request): is_paid = adj.payroll_record is not None + pname = adj.get_type_display() + if adj.project: + pname = f"{adj.get_type_display()} ({adj.project.name})" + elif adj.work_log and adj.work_log.project: + pname = f"{adj.get_type_display()} ({adj.work_log.project.name})" + combined.append({ 'date': adj.date, - 'desc': f"{adj.get_type_display()} - {adj.description}", + 'desc': f"{pname} - {adj.description}", 'workers': adj.worker.name, 'amount': amt, 'status': "Paid" if is_paid else "Pending", @@ -774,7 +788,7 @@ def payroll_dashboard(request): status_filter = request.GET.get('status', 'pending') # pending, paid, all, loans # Common Analytics (prefetch all related data to avoid per-worker queries) - outstanding_total = 0 + outstanding_total = Decimal('0.00') active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related( Prefetch( 'work_logs', @@ -785,7 +799,7 @@ def payroll_dashboard(request): ), Prefetch( 'adjustments', - queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True), + queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project', 'work_log', 'work_log__project'), to_attr='pending_adjustments_list' ), ) @@ -867,27 +881,34 @@ def payroll_dashboard(request): Prefetch('logs', queryset=WorkLog.objects.prefetch_related('workers', 'paid_in')), ) - # Bulk-fetch all pending project-linked adjustments in one query - pending_proj_adjs = {} + # Bulk-fetch all project-linked adjustments + all_proj_adjs = {} for adj in PayrollAdjustment.objects.filter( - work_log__project__isnull=False, - work_log__project__is_active=True, - payroll_record__isnull=True, - ).select_related('work_log'): - pid = adj.work_log.project_id - pending_proj_adjs.setdefault(pid, []).append(adj) + Q(project__isnull=False) | Q(work_log__project__isnull=False) + ).select_related('work_log', 'project'): + pid = adj.project_id or adj.work_log.project_id + all_proj_adjs.setdefault(pid, []).append(adj) for project in active_projects: # 1. Total Historical Cost - cost = 0 + cost = Decimal('0.00') + # WorkLogs for log in project.logs.all(): for worker in log.workers.all(): cost += worker.day_rate + + # Adjustments + for adj in all_proj_adjs.get(project.id, []): + if adj.type in ADDITIVE_TYPES: + cost += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + cost -= adj.amount + if cost > 0: project_costs.append({'name': project.name, 'cost': cost}) # 2. Outstanding Cost (Unpaid) - outstanding_cost = 0 + outstanding_cost = Decimal('0.00') # Unpaid WorkLogs (check paid_in in Python using prefetch cache) for log in project.logs.all(): @@ -895,12 +916,13 @@ def payroll_dashboard(request): for worker in log.workers.all(): outstanding_cost += worker.day_rate - # Unpaid Adjustments linked to this project (from bulk-fetched dict) - for adj in pending_proj_adjs.get(project.id, []): - if adj.type in ADDITIVE_TYPES: - outstanding_cost += adj.amount - elif adj.type in DEDUCTIVE_TYPES: - outstanding_cost -= adj.amount + # Unpaid Adjustments linked to this project + for adj in all_proj_adjs.get(project.id, []): + if adj.payroll_record_id is None: + if adj.type in ADDITIVE_TYPES: + outstanding_cost += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + outstanding_cost -= adj.amount if outstanding_cost > 0: outstanding_project_costs.append({'name': project.name, 'cost': outstanding_cost}) @@ -960,10 +982,11 @@ def payroll_dashboard(request): ot_history_totals = [ot_by_month.get(ym, 0) for ym in chart_months] - # 1 query + prefetch: all work logs in the 6-month period for per-project costs + # Per-project costs (last 6 months) all_project_names = list(Project.objects.values_list('name', flat=True).order_by('name')) project_monthly = {name: [0] * len(chart_months) for name in all_project_names} + # 1. Work Logs all_chart_logs = WorkLog.objects.filter( date__gte=chart_start ).select_related('project').prefetch_related('workers') @@ -977,6 +1000,26 @@ def payroll_dashboard(request): for worker in log.workers.all(): project_monthly[pname][idx] += float(worker.day_rate) + # 2. Adjustments + all_chart_adjs = PayrollAdjustment.objects.filter( + date__gte=chart_start, + payroll_record__isnull=False # Only count paid adjustments for historical chart? Or all? + # Actually WorkLog chart includes all logs. Let's include all adjustments for consistency. + ).select_related('project', 'work_log', 'work_log__project') + + for adj in all_chart_adjs: + month_key = (adj.date.year, adj.date.month) + idx = chart_month_index.get(month_key) + if idx is not None: + project = adj.project or (adj.work_log.project if adj.work_log else None) + if project: + pname = project.name + if pname in project_monthly: + amt = float(adj.amount) + if adj.type in DEDUCTIVE_TYPES: + amt = -amt + project_monthly[pname][idx] += amt + # Filter out projects with zero cost across all months project_chart_data = [ {'name': name, 'data': costs} @@ -1185,7 +1228,8 @@ def price_overtime(request): amount=amount, date=worklog.date, description=f"Overtime: {worklog.get_overtime_display()} @ {rate_pct}% - {worklog.date.strftime('%d %b %Y')}", - work_log=worklog + work_log=worklog, + project=worklog.project # Link project directly ) created += 1 @@ -1296,6 +1340,10 @@ def add_adjustment(request): messages.error(request, "Amount must be greater than zero.") return redirect('payroll_dashboard') + project = None + if project_id: + project = get_object_or_404(Project, pk=project_id) + if worker_ids and amount and adj_type: success_names = [] skip_names = [] @@ -1335,10 +1383,6 @@ def add_adjustment(request): if project_id: # Try to find a worklog for this worker and project to link the adjustment to the project work_log_link = worker.work_logs.filter(project_id=project_id).order_by('-date').first() - if not work_log_link: - # Fallback: any worklog for project - from .models import WorkLog - work_log_link = WorkLog.objects.filter(project_id=project_id).order_by('-date').first() PayrollAdjustment.objects.create( worker=worker, @@ -1347,6 +1391,7 @@ def add_adjustment(request): description=description or 'Advance payment', date=date, work_log=work_log_link, + project=project ) advance_date = date if isinstance(date, datetime.date) else timezone.now().date() @@ -1354,7 +1399,6 @@ def add_adjustment(request): worker=worker, amount=advance_amount, date=advance_date, - type='ADVANCE', notes=description or 'Advance payment' ) @@ -1416,10 +1460,6 @@ def add_adjustment(request): if project_id: # Try to find a worklog for this worker and project to link the adjustment to the project work_log_link = worker.work_logs.filter(project_id=project_id).order_by('-date').first() - if not work_log_link: - # Fallback: any worklog for project - from .models import WorkLog - work_log_link = WorkLog.objects.filter(project_id=project_id).order_by("-date").first() PayrollAdjustment.objects.create( worker=worker, @@ -1428,7 +1468,8 @@ def add_adjustment(request): description=description, date=date, loan=loan, - work_log=work_log_link + work_log=work_log_link, + project=project ) success_names.append(worker.name) @@ -1479,12 +1520,13 @@ def edit_adjustment(request, pk): adj.date = date if project_id: + project = get_object_or_404(Project, pk=project_id) + adj.project = project + # Try to update work_log link too work_log_link = adj.worker.work_logs.filter(project_id=project_id).order_by('-date').first() - if not work_log_link: - from .models import WorkLog - work_log_link = WorkLog.objects.filter(project_id=project_id).order_by('-date').first() adj.work_log = work_log_link elif project_id == '': + adj.project = None adj.work_log = None # Only allow type change for BONUS/DEDUCTION (others have linked objects) @@ -1630,4 +1672,4 @@ def create_receipt(request): return render(request, 'core/create_receipt.html', { 'form': form, 'items': items - }) \ No newline at end of file + })