ld"> 68
+                ('events_count', models.PositiveIntegerField(default=0)),
69
+                ('approved_kgco2e', models.DecimalField(decimal_places=6, default=0, max_digits=18)),
70
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carbon_balances', to='orgs.organization')),
71
+            ],
72
+            options={
73
+                'indexes': [models.Index(fields=['organization', 'year', 'month'], name='recycle_cor_organiz_1160f2_idx')],
74
+                'unique_together': {('organization', 'year', 'month')},
75
+            },
76
+        ),
77
+    ]

+ 19 - 0
recycle_core/migrations/0004_alter_weighticket_pickup.py

@@ -0,0 +1,19 @@
1
+# Generated by Django 4.2.24 on 2025-09-30 09:56
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('recycle_core', '0003_materialemissionfactor_carbonevent_carbonbalance'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.AlterField(
15
+            model_name='weighticket',
16
+            name='pickup',
17
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='weigh_tickets', to='recycle_core.pickuporder'),
18
+        ),
19
+    ]

+ 31 - 0
recycle_core/migrations/0005_weighticket_finalized_at_weighticket_finalized_by_and_more.py

@@ -0,0 +1,31 @@
1
+# Generated by Django 4.2.24 on 2025-09-30 14:36
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+        ('recycle_core', '0004_alter_weighticket_pickup'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.AddField(
17
+            model_name='weighticket',
18
+            name='finalized_at',
19
+            field=models.DateTimeField(blank=True, null=True),
20
+        ),
21
+        migrations.AddField(
22
+            model_name='weighticket',
23
+            name='finalized_by',
24
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='finalized_tickets', to=settings.AUTH_USER_MODEL),
25
+        ),
26
+        migrations.AddField(
27
+            model_name='weighticket',
28
+            name='is_finalized',
29
+            field=models.BooleanField(default=False),
30
+        ),
31
+    ]

+ 17 - 0
recycle_core/migrations/0006_alter_weighticket_options.py

@@ -0,0 +1,17 @@
1
+# Generated by Django 4.2.24 on 2025-09-30 14:58
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('recycle_core', '0005_weighticket_finalized_at_weighticket_finalized_by_and_more'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterModelOptions(
14
+            name='weighticket',
15
+            options={'permissions': (('generate_invoice', 'Can generate invoice from weigh ticket'), ('edit_weigh_ticket', 'Can edit weigh ticket weights'))},
16
+        ),
17
+    ]

+ 90 - 1
recycle_core/models.py

@@ -244,7 +244,7 @@ class PickupItem(TimestampedModel):
244 244
 
245 245
 
246 246
 class WeighTicket(TimestampedModel):
247
-    pickup = models.OneToOneField(PickupOrder, on_delete=models.CASCADE, related_name="weigh_ticket")
247
+    pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="weigh_tickets")
248 248
     ticket_number = models.CharField(max_length=64, blank=True)
249 249
     gross_weight = models.DecimalField(max_digits=12, decimal_places=3)
250 250
     tare_weight = models.DecimalField(max_digits=12, decimal_places=3)
@@ -252,10 +252,15 @@ class WeighTicket(TimestampedModel):
252 252
     unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
253 253
     recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
254 254
     recorded_at = models.DateTimeField(default=timezone.now)
255
+    # Finalization controls
256
+    is_finalized = models.BooleanField(default=False)
257
+    finalized_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="finalized_tickets")
258
+    finalized_at = models.DateTimeField(null=True, blank=True)
255 259
 
256 260
     class Meta:
257 261
         permissions = (
258 262
             ("generate_invoice", "Can generate invoice from weigh ticket"),
263
+            ("edit_weigh_ticket", "Can edit weigh ticket weights"),
259 264
         )
260 265
 
261 266
 
@@ -388,3 +393,87 @@ class ScrapListingInvite(TimestampedModel):
388 393
 
389 394
     class Meta:
390 395
         unique_together = ("listing", "invited_org")
396
+
397
+
398
+# Carbon accounting models ---------------------------------------------------
399
+
400
+
401
+class MaterialEmissionFactor(TimestampedModel):
402
+    """Emission factor for a given material per unit.
403
+
404
+    Values are in kgCO2e per given unit (default kg). Factors can be
405
+    organization-specific and time-bounded via effective dates.
406
+    """
407
+
408
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_factors")
409
+    material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="emission_factors")
410
+    unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
411
+    kgco2e_per_unit = models.DecimalField(max_digits=16, decimal_places=6)
412
+    source = models.CharField(max_length=255, blank=True)
413
+    effective_from = models.DateField(null=True, blank=True)
414
+    effective_to = models.DateField(null=True, blank=True)
415
+
416
+    class Meta:
417
+        indexes = [
418
+            models.Index(fields=["organization", "material", "unit"]),
419
+        ]
420
+        unique_together = ()
421
+
422
+    def __str__(self) -> str:  # pragma: no cover - simple display
423
+        return f"{self.material.name} {self.kgco2e_per_unit} kgCO2e/{self.unit}"
424
+
425
+
426
+class CarbonEvent(TimestampedModel):
427
+    """Calculated carbon impact event (e.g., from a weigh ticket line).
428
+
429
+    Events flow through an approvals queue before contributing to monthly
430
+    CarbonBalance aggregates.
431
+    """
432
+
433
+    STATUS_PENDING = "pending"
434
+    STATUS_APPROVED = "approved"
435
+    STATUS_REJECTED = "rejected"
436
+    STATUS_CHOICES = (
437
+        (STATUS_PENDING, "Pending"),
438
+        (STATUS_APPROVED, "Approved"),
439
+        (STATUS_REJECTED, "Rejected"),
440
+    )
441
+
442
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_events")
443
+    weigh_ticket = models.ForeignKey(WeighTicket, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
444
+    # Optional linkage to a specific line for traceability
445
+    weigh_line = models.ForeignKey(WeighLine, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
446
+    material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
447
+    quantity = models.DecimalField(max_digits=14, decimal_places=3)
448
+    unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
449
+    kgco2e = models.DecimalField(max_digits=18, decimal_places=6)
450
+    event_date = models.DateTimeField(default=timezone.now, db_index=True)
451
+
452
+    status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True)
453
+    approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="approved_carbon_events")
454
+    approved_at = models.DateTimeField(null=True, blank=True)
455
+    notes = models.TextField(blank=True)
456
+
457
+    class Meta:
458
+        indexes = [
459
+            models.Index(fields=["organization", "status"]),
460
+            models.Index(fields=["organization", "event_date"]),
461
+        ]
462
+        unique_together = ("weigh_line",)
463
+
464
+
465
+class CarbonBalance(TimestampedModel):
466
+    """Monthly aggregation of approved CarbonEvents per organization."""
467
+
468
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_balances")
469
+    year = models.PositiveSmallIntegerField()
470
+    month = models.PositiveSmallIntegerField()
471
+    events_count = models.PositiveIntegerField(default=0)
472
+    approved_kgco2e = models.DecimalField(max_digits=18, decimal_places=6, default=0)
473
+
474
+    class Meta:
475
+        unique_together = ("organization", "year", "month")
476
+        indexes = [models.Index(fields=["organization", "year", "month"])]
477
+
478
+    def __str__(self) -> str:  # pragma: no cover - simple display
479
+        return f"{self.organization.code} {self.year}-{self.month:02d}"

+ 69 - 21
recycle_core/services/billing.py

@@ -26,8 +26,8 @@ def _get_price(price_list: PriceList, material_id: int, unit: str, direction: st
26 26
 
27 27
 
28 28
 def generate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
29
-    if getattr(pickup, "weigh_ticket", None) is None:
30
-        raise ValueError("Pickup has no weigh ticket")
29
+    if not pickup.weigh_tickets.exists():
30
+        raise ValueError("Pickup has no weigh tickets")
31 31
 
32 32
     # If invoice already exists and not void, return it (idempotent)
33 33
     existing = pickup.invoices.filter(status__in=[Invoice.STATUS_DRAFT, Invoice.STATUS_ISSUED, Invoice.STATUS_PAID]).first()
@@ -52,25 +52,26 @@ def generate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
52 52
     total = Decimal("0.00")
53 53
     payout_total = Decimal("0.00")
54 54
 
55
-    for line in pickup.weigh_ticket.lines.all():
56
-        sell_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_SELL)
57
-        buy_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_BUY)
58
-
59
-        if sell_price is not None and sell_price > 0:
60
-            line_total = (Decimal(line.quantity) * Decimal(sell_price)).quantize(Decimal("0.01"))
61
-            InvoiceLine.objects.create(
62
-                invoice=inv,
63
-                description=f"{line.material.name} ({line.unit})",
64
-                material=line.material,
65
-                quantity=line.quantity,
66
-                unit=line.unit,
67
-                unit_price=sell_price,
68
-                line_total=line_total,
69
-            )
70
-            total += line_total
71
-
72
-        if buy_price is not None and buy_price > 0:
73
-            payout_total += (Decimal(line.quantity) * Decimal(buy_price)).quantize(Decimal("0.01"))
55
+    for t in pickup.weigh_tickets.all():
56
+        for line in t.lines.all():
57
+            sell_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_SELL)
58
+            buy_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_BUY)
59
+
60
+            if sell_price is not None and sell_price > 0:
61
+                line_total = (Decimal(line.quantity) * Decimal(sell_price)).quantize(Decimal("0.01"))
62
+                InvoiceLine.objects.create(
63
+                    invoice=inv,
64
+                    description=f"{line.material.name} ({line.unit})",
65
+                    material=line.material,
66
+                    quantity=line.quantity,
67
+                    unit=line.unit,
68
+                    unit_price=sell_price,
69
+                    line_total=line_total,
70
+                )
71
+                total += line_total
72
+
73
+            if buy_price is not None and buy_price > 0:
74
+                payout_total += (Decimal(line.quantity) * Decimal(buy_price)).quantize(Decimal("0.01"))
74 75
 
75 76
     inv.total_amount = total
76 77
     inv.issued_at = timezone.now()
@@ -94,3 +95,50 @@ def generate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
94 95
     pickup.save(update_fields=["status"]) 
95 96
 
96 97
     return inv
98
+
99
+
100
+def regenerate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
101
+    existing = pickup.invoices.filter(status__in=[Invoice.STATUS_DRAFT, Invoice.STATUS_ISSUED, Invoice.STATUS_PAID]).first()
102
+    if not existing:
103
+        return generate_invoice_for_pickup(pickup)
104
+    if existing.status != Invoice.STATUS_DRAFT:
105
+        raise ValueError("Invoice not in draft status; cannot regenerate")
106
+
107
+    price_list = pickup.customer.price_list
108
+    if not price_list:
109
+        price_list = PriceList.objects.filter(organization=pickup.organization).order_by("id").first()
110
+    if not price_list:
111
+        raise ValueError("No price list found for customer or organization")
112
+
113
+    # Clear lines and rebuild
114
+    InvoiceLine.objects.filter(invoice=existing).delete()
115
+
116
+    total = Decimal("0.00")
117
+    payout_total = Decimal("0.00")
118
+    for t in pickup.weigh_tickets.all():
119
+        for line in t.lines.all():
120
+            sell_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_SELL)
121
+            buy_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_BUY)
122
+
123
+            if sell_price is not None and sell_price > 0:
124
+                line_total = (Decimal(line.quantity) * Decimal(sell_price)).quantize(Decimal("0.01"))
125
+                InvoiceLine.objects.create(
126
+                    invoice=existing,
127
+                    description=f"{line.material.name} ({line.unit})",
128
+                    material=line.material,
129
+                    quantity=line.quantity,
130
+                    unit=line.unit,
131
+                    unit_price=sell_price,
132
+                    line_total=line_total,
133
+                )
134
+                total += line_total
135
+
136
+            if buy_price is not None and buy_price > 0:
137
+                payout_total += (Decimal(line.quantity) * Decimal(buy_price)).quantize(Decimal("0.01"))
138
+
139
+    existing.total_amount = total
140
+    # Keep draft; do not auto issue
141
+    if not existing.currency_code:
142
+        existing.currency_code = price_list.currency_code
143
+    existing.save(update_fields=["total_amount", "currency_code", "updated_at"])
144
+    return existing

+ 170 - 0
recycle_core/services/carbon.py

@@ -0,0 +1,170 @@
1
+from __future__ import annotations
2
+
3
+from datetime import date
4
+from decimal import Decimal
5
+from typing import Iterable
6
+
7
+from django.db import transaction
8
+from django.db.models import Q
9
+from django.utils import timezone
10
+
11
+from ..models import (
12
+    Organization,
13
+    Material,
14
+    WeighTicket,
15
+    WeighLine,
16
+    MaterialEmissionFactor,
17
+    CarbonEvent,
18
+    CarbonBalance,
19
+)
20
+
21
+
22
+LB_TO_KG = Decimal("0.45359237")
23
+
24
+
25
+def _convert_qty(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
26
+    if from_unit == to_unit:
27
+        return qty
28
+    # Minimal support: lb <-> kg; pcs assumed 1:1 (no conversion)
29
+    if from_unit == Material.UNIT_LB and to_unit == Material.UNIT_KG:
30
+        return (qty * LB_TO_KG).quantize(Decimal("0.000"))
31
+    if from_unit == Material.UNIT_KG and to_unit == Material.UNIT_LB:
32
+        return (qty / LB_TO_KG).quantize(Decimal("0.000"))
33
+    return qty
34
+
35
+
36
+def get_applicable_factor(org: Organization, material: Material, unit: str, as_of: date | None = None) -> MaterialEmissionFactor | None:
37
+    """Return the most recent factor effective for the given org/material/unit.
38
+
39
+    If none matches the unit, try the material's default unit as a fallback.
40
+    """
41
+    as_of = as_of or timezone.now().date()
42
+    qs = (
43
+        MaterialEmissionFactor.objects.filter(
44
+            organization=org,
45
+            material=material,
46
+            unit=unit,
47
+        )
48
+        .filter(Q(effective_from__isnull=True) | Q(effective_from__lte=as_of))
49
+        .order_by("-effective_from", "-id")
50
+    )
51
+    fac = qs.first()
52
+    if fac:
53
+        return fac
54
+    # fallback to default unit
55
+    if unit != material.default_unit:
56
+        qs2 = (
57
+            MaterialEmissionFactor.objects.filter(
58
+                organization=org,
59
+                material=material,
60
+                unit=material.default_unit,
61
+            )
62
+            .filter(Q(effective_from__isnull=True) | Q(effective_from__lte=as_of))
63
+            .order_by("-effective_from", "-id")
64
+        )
65
+        return qs2.first()
66
+    return None
67
+
68
+
69
+@transaction.atomic
70
+def create_event_for_line(line: WeighLine) -> CarbonEvent | None:
71
+    """Create or update a pending CarbonEvent for a given weigh line.
72
+
73
+    Returns the event or None if no factor available.
74
+    """
75
+    ticket = line.ticket
76
+    org = ticket.pickup.organization
77
+    fac = get_applicable_factor(org, line.material, line.unit)
78
+    if not fac:
79
+        # Try convert to factor unit
80
+        fac = get_applicable_factor(org, line.material, fac_unit := line.material.default_unit)
81
+        if not fac:
82
+            return None
83
+        qty = _convert_qty(Decimal(line.quantity), line.unit, fac.unit)
84
+    else:
85
+        qty = Decimal(line.quantity)
86
+    kgco2e = (qty * Decimal(fac.kgco2e_per_unit)).quantize(Decimal("0.000001"))
87
+
88
+    ev, _ = CarbonEvent.objects.update_or_create(
89
+        weigh_line=line,
90
+        defaults={
91
+            "organization": org,
92
+            "weigh_ticket": ticket,
93
+            "material": line.material,
94
+            "quantity": qty,
95
+            "unit": fac.unit,
96
+            "kgco2e": kgco2e,
97
+            "event_date": timezone.now(),
98
+            "status": CarbonEvent.STATUS_PENDING,
99
+        },
100
+    )
101
+    return ev
102
+
103
+
104
+def calculate_events_for_ticket(ticket: WeighTicket) -> list[CarbonEvent]:
105
+    events: list[CarbonEvent] = []
106
+    for ln in ticket.lines.select_related("material"):
107
+        ev = create_event_for_line(ln)
108
+        if ev:
109
+            events.append(ev)
110
+    return events
111
+
112
+
113
+def approve_events(org: Organization, events: Iterable[CarbonEvent], *, approver) -> int:
114
+    """Approve events and update monthly CarbonBalance aggregates.
115
+
116
+    Returns number approved.
117
+    """
118
+    approved = 0
119
+    months: set[tuple[int, int]] = set()
120
+    now = timezone.now()
121
+    for ev in events:
122
+        if ev.organization_id != org.id:
123
+            continue
124
+        if ev.status == CarbonEvent.STATUS_APPROVED:
125
+            continue
126
+        ev.status = CarbonEvent.STATUS_APPROVED
127
+        ev.approved_by = approver
128
+        ev.approved_at = now
129
+        ev.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
130
+        months.add((ev.event_date.year, ev.event_date.month))
131
+        approved += 1
132
+    # recompute balances for affected months
133
+    for (y, m) in months:
134
+        recompute_balance_for_month(org, y, m)
135
+    return approved
136
+
137
+
138
+def recompute_balance_for_month(org: Organization, year: int, month: int) -> CarbonBalance:
139
+    qs = CarbonEvent.objects.filter(
140
+        organization=org,
141
+        status=CarbonEvent.STATUS_APPROVED,
142
+        event_date__year=year,
143
+        event_date__month=month,
144
+    )
145
+    # Avoid circular import for Sum; simple loop to sum
146
+    total_val = Decimal("0")
147
+    cnt = 0
148
+    for ev in qs.only("kgco2e"):
149
+        total_val += Decimal(ev.kgco2e)
150
+        cnt += 1
151
+    bal, _ = CarbonBalance.objects.get_or_create(organization=org, year=year, month=month)
152
+    bal.events_count = cnt
153
+    bal.approved_kgco2e = total_val.quantize(Decimal("0.000001"))
154
+    bal.save(update_fields=["events_count", "approved_kgco2e", "updated_at"])
155
+    return bal
156
+
157
+
158
+def get_totals_for_org(org: Organization) -> dict[str, Decimal]:
159
+    """Convenience totals for dashboards."""
160
+    from django.utils.timezone import now
161
+
162
+    today = now().date()
163
+    y = today.year
164
+    m = today.month
165
+    bal = CarbonBalance.objects.filter(organization=org, year=y, month=m).first()
166
+    pending = CarbonEvent.objects.filter(organization=org, status=CarbonEvent.STATUS_PENDING).count()
167
+    return {
168
+        "pending_events": pending,
169
+        "approved_mtd": (bal.approved_kgco2e if bal else Decimal("0")),
170
+    }

+ 17 - 0
recycle_core/signals.py

@@ -0,0 +1,17 @@
1
+from __future__ import annotations
2
+
3
+from django.db.models.signals import post_save
4
+from django.dispatch import receiver
5
+
6
+from .models import WeighLine
7
+from .services.carbon import create_event_for_line
8
+
9
+
10
+@receiver(post_save, sender=WeighLine)
11
+def weigh_line_saved(sender, instance: WeighLine, created: bool, **kwargs):
12
+    try:
13
+        create_event_for_line(instance)
14
+    except Exception:
15
+        # Fail silently to not block core operations
16
+        pass
17
+

+ 64 - 0
recycle_core/templates/recycle_core/carbon_events_list.html

@@ -0,0 +1,64 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% load i18n humanize %}
3
+{% block title %}{% trans "Carbon Events" %}{% endblock %}
4
+{% block content %}
5
+<div class="max-w-6xl mx-auto">
6
+  <h1 class="text-2xl font-semibold mb-4">{% trans "Carbon Events" %}</h1>
7
+  <div class="bg-white rounded shadow p-4 mb-4">
8
+    <form method="get" class="grid md:grid-cols-4 gap-3">
9
+      {{ filter.form.organization }}
10
+      {{ filter.form.status }}
11
+      {{ filter.form.event_date }}
12
+      <div>
13
+        <button class="px-3 py-2 rounded bg-blue-600 text-white">{% trans "Filter" %}</button>
14
+      </div>
15
+    </form>
16
+  </div>
17
+  <form method="post" class="bg-white rounded shadow">
18
+    {% csrf_token %}
19
+    <div class="p-3 flex items-center gap-2 border-b">
20
+      <select name="action" class="border rounded px-2 py-1">
21
+        <option value="">-- {% trans "Bulk action" %} --</option>
22
+        <option value="approve">{% trans "Approve" %}</option>
23
+        <option value="reject">{% trans "Reject" %}</option>
24
+      </select>
25
+      <button class="px-3 py-1 rounded bg-gray-800 text-white">{% trans "Apply" %}</button>
26
+    </div>
27
+    <div class="overflow-x-auto">
28
+      <table class="min-w-full text-sm">
29
+        <thead>
30
+          <tr class="text-left text-gray-600">
31
+            <th class="px-3 py-2"><input type="checkbox" onclick="document.querySelectorAll('input[name=\'ids\']').forEach(cb=>cb.checked=this.checked)"></th>
32
+            <th class="px-3 py-2">{% trans "Date" %}</th>
33
+            <th class="px-3 py-2">{% trans "Organization" %}</th>
34
+            <th class="px-3 py-2">{% trans "Ticket" %}</th>
35
+            <th class="px-3 py-2">{% trans "Material" %}</th>
36
+            <th class="px-3 py-2 text-right">{% trans "Qty" %}</th>
37
+            <th class="px-3 py-2">{% trans "Unit" %}</th>
38
+            <th class="px-3 py-2 text-right">{% trans "kgCO2e" %}</th>
39
+            <th class="px-3 py-2">{% trans "Status" %}</th>
40
+          </tr>
41
+        </thead>
42
+        <tbody>
43
+          {% for ev in events %}
44
+          <tr class="border-t">
45
+            <td class="px-3 py-2"><input type="checkbox" name="ids" value="{{ ev.id }}"></td>
46
+            <td class="px-3 py-2 whitespace-nowrap">{{ ev.event_date|date:"Y-m-d H:i" }}</td>
47
+            <td class="px-3 py-2">{{ ev.organization.name }}</td>
48
+            <td class="px-3 py-2">{% if ev.weigh_ticket %}{{ ev.weigh_ticket.ticket_number|default:ev.weigh_ticket.id }}{% else %}-{% endif %}</td>
49
+            <td class="px-3 py-2">{{ ev.material|default:"-" }}</td>
50
+            <td class="px-3 py-2 text-right">{{ ev.quantity|floatformat:2|intcomma }}</td>
51
+            <td class="px-3 py-2">{{ ev.unit }}</td>
52
+            <td class="px-3 py-2 text-right">{{ ev.kgco2e|floatformat:2|intcomma }}</td>
53
+            <td class="px-3 py-2">{{ ev.get_status_display }}</td>
54
+          </tr>
55
+          {% empty %}
56
+          <tr><td colspan="9" class="px-3 py-6 text-center text-gray-600">{% trans "No events found." %}</td></tr>
57
+          {% endfor %}
58
+        </tbody>
59
+      </table>
60
+    </div>
61
+    {% include 'admin_frontend/_pagination.html' %}
62
+  </form>
63
+</div>
64
+{% endblock %}

+ 18 - 0
recycle_core/templates/recycle_core/emission_factor_form.html

@@ -0,0 +1,18 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs %}
3
+{% block title %}Edit Emission Factor{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+<div class="max-w-2xl mx-auto bg-white rounded shadow p-4">
7
+  <h1 class="text-xl font-semibold mb-3">Edit Emission Factor</h1>
8
+  <form method="post">
9
+    {% csrf_token %}
10
+    {% include 'admin_frontend/_form.html' with form=form submit_label='Save' %}
11
+  </form>
12
+  <div class="mt-3">
13
+    <a class="btn-outline" href="{{ cancel_url }}">Cancel</a>
14
+  </div>
15
+  
16
+</div>
17
+{% endblock %}
18
+

+ 72 - 0
recycle_core/templates/recycle_core/emission_factors_list.html

@@ -0,0 +1,72 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags breadcrumbs %}
3
+{% block title %}Emission Factors{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+<div class="flex items-center justify-between mb-4">
7
+  <h1 class="text-2xl font-semibold">Emission Factors</h1>
8
+  <details class="relative">
9
+    <summary class="cursor-pointer px-3 py-2 bg-blue-600 text-white rounded">New Factor</summary>
10
+    <div class="absolute right-0 mt-2 bg-white shadow rounded p-4 w-96">
11
+      {% include 'admin_frontend/_form.html' with form=form submit_label='Create' %}
12
+    </div>
13
+  </details>
14
+  
15
+</div>
16
+
17
+<div class="bg-white rounded shadow p-4 mb-4">
18
+  <form method="get" class="space-y-3 flex gap-2">
19
+    {{ filter.form|crispy }}
20
+    <div class="flex items-center gap-2">
21
+      <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
22
+      <a href="{% url 'recycle_core:emission_factors_list' %}" class="btn-outline">Reset</a>
23
+    </div>
24
+  </form>
25
+</div>
26
+
27
+<div class="bg-white rounded shadow">
28
+  <div class="p-4 border-b">
29
+    <h2 class="font-medium">Factors</h2>
30
+  </div>
31
+  <div class="overflow-x-auto">
32
+    <table class="min-w-full">
33
+      <thead>
34
+        <tr class="text-left text-sm text-gray-600">
35
+          <th class="px-4 py-2">Organization</th>
36
+          <th class="px-4 py-2">Material</th>
37
+          <th class="px-4 py-2">Unit</th>
38
+          <th class="px-4 py-2">kgCO2e/unit</th>
39
+          <th class="px-4 py-2">Source</th>
40
+          <th class="px-4 py-2">Effective</th>
41
+          <th class="px-4 py-2">Actions</th>
42
+        </tr>
43
+      </thead>
44
+      <tbody class="divide-y">
45
+        {% for f in factors %}
46
+        <tr>
47
+          <td class="px-4 py-2">{{ f.organization.name }}</td>
48
+          <td class="px-4 py-2">{{ f.material.name }}</td>
49
+          <td class="px-4 py-2">{{ f.unit }}</td>
50
+          <td class="px-4 py-2">{{ f.kgco2e_per_unit }}</td>
51
+          <td class="px-4 py-2">{{ f.source|default:'-' }}</td>
52
+          <td class="px-4 py-2">{{ f.effective_from|date:'Y-m-d' }}{% if f.effective_to %} → {{ f.effective_to|date:'Y-m-d' }}{% endif %}</td>
53
+          <td class="px-4 py-2 space-x-2">
54
+            <a class="text-blue-600 hover:underline" href="{% url 'recycle_core:emission_factor_edit' f.id %}">Edit</a>
55
+            <form method="post" action="{% url 'recycle_core:emission_factor_delete' f.id %}" class="inline">
56
+              {% csrf_token %}
57
+              <button class="text-red-600 hover:underline" onclick="return confirm('Delete factor?')">Delete</button>
58
+            </form>
59
+          </td>
60
+        </tr>
61
+        {% empty %}
62
+        <tr>
63
+          <td class="px-4 py-4 text-gray-500" colspan="7">No factors.</td>
64
+        </tr>
65
+        {% endfor %}
66
+      </tbody>
67
+    </table>
68
+  </div>
69
+  {% include 'admin_frontend/_pagination.html' %}
70
+</div>
71
+{% endblock %}
72
+

+ 116 - 0
recycle_core/templates/recycle_core/invoice_detail.html

@@ -0,0 +1,116 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs humanize %}
3
+{% block title %}Invoice #{{ invoice.id }}{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+
7
+<div class="flex items-center justify-between mb-4">
8
+  <div class="flex items-center gap-3">
9
+    {% if invoice.pickup_id %}
10
+      <a class="btn-outline" href="{% url 'recycle_core:pickup_detail' invoice.pickup_id %}">Back to Pickup</a>
11
+    {% endif %}
12
+    <h1 class="text-2xl font-semibold">Invoice #{{ invoice.id }}</h1>
13
+  </div>
14
+  <div class="text-sm text-gray-600">Status: <span class="px-2 py-1 rounded bg-gray-100">{{ invoice.status }}</span></div>
15
+</div>
16
+
17
+<div class="grid grid-cols-1 md:grid-cols-10 gap-6">
18
+  <div class="bg-white rounded shadow p-4 md:col-span-4">
19
+    <h2 class="font-medium mb-2">Details</h2>
20
+    <dl class="grid grid-cols-3 gap-2 text-sm">
21
+      <dt class="text-gray-500">Organization</dt><dd class="col-span-2">{{ invoice.organization.name }}</dd>
22
+      <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ invoice.customer.name }}</dd>
23
+      <dt class="text-gray-500">Issued</dt><dd class="col-span-2">{{ invoice.issued_at|default:"-" }}</dd>
24
+      <dt class="text-gray-500">Due</dt><dd class="col-span-2">{{ invoice.due_at|default:"-" }}</dd>
25
+      <dt class="text-gray-500">Currency</dt><dd class="col-span-2">{{ invoice.currency_code }}</dd>
26
+      <dt class="text-gray-500">Total</dt><dd class="col-span-2 text-right">{{ invoice.total_amount|floatformat:2|intcomma }} {{ invoice.currency_code }}</dd>
27
+      <dt class="text-gray-500">Paid</dt><dd class="col-span-2 text-right">{{ payments_total|floatformat:2|intcomma }} {{ invoice.currency_code }}</dd>
28
+      <dt class="text-gray-500">Balance</dt><dd class="col-span-2 text-right">{{ balance|floatformat:2|intcomma }} {{ invoice.currency_code }}</dd>
29
+    </dl>
30
+  </div>
31
+  <div class="bg-white rounded shadow p-4 md:col-span-6">
32
+    <h2 class="font-medium mb-2">Lines</h2>
33
+    <table class="min-w-full">
34
+      <thead>
35
+        <tr class="text-left text-sm text-gray-600">
36
+          <th class="px-2 py-1">Description</th>
37
+          <th class="px-2 py-1 text-right">Qty</th>
38
+          <th class="px-2 py-1 text-right">Unit</th>
39
+          <th class="px-2 py-1 text-right">Unit Price</th>
40
+          <th class="px-2 py-1 text-right">Line Total</th>
41
+        </tr>
42
+      </thead>
43
+      <tbody class="divide-y">
44
+        {% for line in invoice.lines.all %}
45
+        <tr>
46
+          <td class="px-2 py-1">{{ line.description|default:"-" }}</td>
47
+          <td class="px-2 py-1 text-right">{{ line.quantity|floatformat:3|intcomma }}</td>
48
+          <td class="px-2 py-1 text-right">{{ line.unit }}</td>
49
+          <td class="px-2 py-1 text-right">{{ line.unit_price|floatformat:2|intcomma }}</td>
50
+          <td class="px-2 py-1 text-right">{{ line.line_total|floatformat:2|intcomma }}</td>
51
+        </tr>
52
+        {% empty %}
53
+        <tr>
54
+          <td class="px-2 py-2 text-gray-500" colspan="5">No lines.</td>
55
+        </tr>
56
+        {% endfor %}
57
+      </tbody>
58
+    </table>
59
+  </div>
60
+</div>
61
+
62
+<div class="mt-6 grid grid-cols-1 md:grid-cols-10 gap-6">
63
+  <div class="bg-white rounded shadow p-4 md:col-span-6">
64
+    <h2 class="font-medium mb-2">Payments</h2>
65
+    <table class="min-w-full">
66
+      <thead>
67
+        <tr class="text-left text-sm text-gray-600">
68
+          <th class="px-2 py-1">Amount</th>
69
+          <th class="px-2 py-1">Received At</th>
70
+          <th class="px-2 py-1">Reference</th>
71
+        </tr>
72
+      </thead>
73
+      <tbody class="divide-y">
74
+        {% for p in invoice.payments.all %}
75
+        <tr>
76
+          <td class="px-2 py-1">{{ p.amount|floatformat:2|intcomma }} {{ invoice.currency_code }}</td>
77
+          <td class="px-2 py-1">{{ p.received_at|default:"-" }}</td>
78
+          <td class="px-2 py-1">{{ p.reference|default:"-" }}</td>
79
+        </tr>
80
+        {% empty %}
81
+        <tr>
82
+          <td class="px-2 py-2 text-gray-500" colspan="3">No payments recorded.</td>
83
+        </tr>
84
+        {% endfor %}
85
+      </tbody>
86
+    </table>
87
+  </div>
88
+  <div class="bg-white rounded shadow p-4 md:col-span-4">
89
+    <h2 class="font-medium mb-2">Add Payment</h2>
90
+    <form method="post" action="{% url 'billing:invoice_add_payment' invoice.id %}">
91
+      {% csrf_token %}
92
+      {% include 'admin_frontend/_form.html' with form=pay_form submit_label='Record Payment' %}
93
+    </form>
94
+  </div>
95
+  <div class="bg-white rounded shadow p-4 md:col-span-4">
96
+    <h2 class="font-medium mb-2">Update Status</h2>
97
+    <form method="post" action="{% url 'billing:invoice_set_status' invoice.id %}">
98
+      {% csrf_token %}
99
+      <div class="flex items-end gap-3">
100
+        <div>
101
+          <label class="block text-sm text-gray-600 mb-1">Status</label>
102
+          <select name="status" class="border rounded px-3 py-2">
103
+            {% for code,label in status_choices %}
104
+              <option value="{{ code }}" {% if invoice.status == code %}selected{% endif %}>{{ label }}</option>
105
+            {% endfor %}
106
+          </select>
107
+        </div>
108
+        <div>
109
+          <button class="px-3 py-2 rounded bg-gray-800 text-white">Update</button>
110
+        </div>
111
+      </div>
112
+    </form>
113
+  </div>
114
+</div>
115
+
116
+{% endblock %}

+ 2 - 1
recycle_core/templates/recycle_core/invoices_list.html

@@ -36,7 +36,7 @@
36 36
     <tbody class="divide-y divide-gray-200">
37 37
       {% for inv in invoices %}
38 38
       <tr>
39
-        <td class="px-4 py-2">{{ inv.customer.name }}</td>
39
+        <td class="px-4 py-2"><a class="text-blue-700 hover:underline" href="{% url 'billing:invoice_detail' inv.id %}">{{ inv.customer.name }}</a></td>
40 40
         <td class="px-4 py-2">{{ inv.total_amount }} {{ inv.currency_code }}</td>
41 41
         <td class="px-4 py-2">{{ inv.payments_total|default:0 }} {{ inv.currency_code }}</td>
42 42
         <td class="px-4 py-2">{{ inv.balance }} {{ inv.currency_code }}</td>
@@ -56,6 +56,7 @@
56 56
                 </form>
57 57
               </div>
58 58
             </details>
59
+            <a class="btn-outline btn-xs" href="{% url 'billing:invoice_detail' inv.id %}">View</a>
59 60
           </div>
60 61
         </td>
61 62
       </tr>

+ 97 - 23
recycle_core/templates/recycle_core/pickup_detail.html

@@ -1,5 +1,5 @@
1 1
 {% extends "admin_frontend/base.html" %}
2
-{% load breadcrumbs %}
2
+{% load breadcrumbs humanize %}
3 3
 {% block title %}Pickup #{{ pickup.id }}{% endblock %}
4 4
 {% block content %}
5 5
 {% render_breadcrumbs breadcrumbs %}
@@ -7,24 +7,20 @@
7 7
 <div class="flex items-center justify-between mb-4">
8 8
   <h1 class="text-2xl font-semibold">Pickup #{{ pickup.id }}</h1>
9 9
   <div class="flex items-center gap-2">
10
-    {% if pickup.weigh_ticket %}
11
-      <a class="btn-outline" href="{% url 'recycle_core:weigh_ticket_detail' pickup.weigh_ticket.id %}">View Weigh Ticket</a>
12
-      {% if perms.recycle_core.generate_invoice %}
10
+    {% if perms.recycle_core.create_weigh_ticket %}
11
+      <a class="px-3 py-2 rounded bg-blue-600 text-white" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
12
+    {% endif %}
13
+    {% if perms.recycle_core.generate_invoice %}
13 14
       <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' pickup.id %}">
14 15
         {% csrf_token %}
15 16
         <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
16 17
       </form>
17
-      {% endif %}
18
-    {% else %}
19
-      {% if perms.recycle_core.create_weigh_ticket %}
20
-      <a class="px-3 py-2 rounded bg-blue-600 text-white" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
21
-      {% endif %}
22 18
     {% endif %}
23 19
   </div>
24 20
   
25 21
 </div>
26 22
 
27
-<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
23
+<div class="grid grid-cols-1 gap-6">
28 24
   <div class="bg-white rounded shadow p-4">
29 25
     <h2 class="font-medium mb-2">Details</h2>
30 26
     <dl class="grid grid-cols-3 gap-2 text-sm">
@@ -60,48 +56,126 @@
60 56
         {% endfor %}
61 57
       </tbody>
62 58
     </table>
59
+    <div class="mt-4 border-t pt-4">
60
+      <h3 class="font-medium mb-2">Add Item</h3>
61
+      <form method="post">
62
+        {% csrf_token %}
63
+        <input type="hidden" name="action" value="add_item">
64
+        <div class="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
65
+          <div>
66
+            <label class="block text-sm text-gray-600 mb-1">Material</label>
67
+            {{ add_item_form.material }}
68
+          </div>
69
+          <div>
70
+            <label class="block text-sm text-gray-600 mb-1">Est. Quantity</label>
71
+            {{ add_item_form.estimated_qty }}
72
+          </div>
73
+          <div>
74
+            <label class="block text-sm text-gray-600 mb-1">Unit</label>
75
+            {{ add_item_form.unit }}
76
+          </div>
77
+          <div>
78
+            <button class="px-3 py-2 rounded bg-blue-600 text-white">Add</button>
79
+          </div>
80
+        </div>
81
+      </form>
82
+    </div>
63 83
   </div>
64 84
 </div>
65 85
 
66
-<div class="mt-6 bg-white rounded shadow p-4">
86
+<div id="tickets" class="mt-6 bg-white rounded shadow p-4">
67 87
   <h2 class="font-medium mb-2">Weigh Tickets</h2>
68
-  <table class="min-w-full">
88
+  <div class="overflow-x-auto">
89
+  <table class="min-w-[1100px]">
69 90
     <thead>
70 91
       <tr class="text-left text-sm text-gray-600">
71 92
         <th class="px-2 py-1">Ticket</th>
72 93
         <th class="px-2 py-1">Recorded</th>
73 94
         <th class="px-2 py-1">Net</th>
95
+        <th class="px-2 py-1 text-right">Unit Price</th>
96
+        <th class="px-2 py-1 text-right">Total</th>
74 97
         <th class="px-2 py-1">Actions</th>
75 98
       </tr>
76 99
     </thead>
77 100
     <tbody class="divide-y">
78
-      {% if pickup.weigh_ticket %}
101
+      {% for t in pickup.weigh_tickets.all %}
79 102
         <tr>
80
-          <td class="px-2 py-1"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:weigh_ticket_detail' pickup.weigh_ticket.id %}">{{ pickup.weigh_ticket.ticket_number|default:pickup.weigh_ticket.id }}</a></td>
81
-          <td class="px-2 py-1">{{ pickup.weigh_ticket.recorded_at }}</td>
82
-          <td class="px-2 py-1">{{ pickup.weigh_ticket.net_weight }} {{ pickup.weigh_ticket.unit }}</td>
103
+          <td class="px-2 py-1"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:weigh_ticket_detail' t.id %}">{{ t.ticket_number|default:t.id }}</a></td>
104
+          <td class="px-2 py-1">{{ t.recorded_at }}</td>
105
+          <td class="px-2 py-1">{{ t.net_weight }} {{ t.unit }}</td>
106
+          <td class="px-2 py-1 text-right">{% if t.avg_unit %}{{ t.avg_unit|floatformat:2|intcomma }} {{ ticket_currency }}{% else %}-{% endif %}</td>
107
+          <td class="px-2 py-1 text-right">{% if t.total_value %}{{ t.total_value|floatformat:2|intcomma }} {{ ticket_currency }}{% else %}-{% endif %}</td>
83 108
           <td class="px-2 py-1">
84 109
             {% if perms.recycle_core.generate_invoice %}
85
-            <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' pickup.weigh_ticket.id %}">
110
+            <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' t.id %}">
86 111
               {% csrf_token %}
87 112
               <button class="btn-outline btn-xs">Generate Invoice</button>
88 113
             </form>
89 114
             {% endif %}
90 115
           </td>
91 116
         </tr>
92
-      {% else %}
117
+      {% empty %}
93 118
         <tr>
94 119
           <td colspan="4" class="px-2 py-2 text-gray-500">No weigh tickets yet.</td>
95 120
         </tr>
96
-      {% endif %}
121
+      {% endfor %}
97 122
     </tbody>
98 123
   </table>
124
+  </div>
99 125
 
100
-  {% if not pickup.weigh_ticket %}
101
-    <div class="mt-3">
126
+  <div class="mt-3">
127
+    {% if perms.recycle_core.create_weigh_ticket %}
102 128
       <a class="btn" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
103
-    </div>
104
-  {% endif %}
129
+    {% endif %}
130
+  </div>
131
+</div>
132
+
133
+<div class="mt-6 bg-white rounded shadow p-4">
134
+  <h2 class="font-medium mb-2">Invoices</h2>
135
+  <div class="overflow-x-auto">
136
+  <table class="min-w-[1100px]">
137
+    <thead>
138
+      <tr class="text-left text-sm text-gray-600">
139
+        <th class="px-2 py-1">Invoice</th>
140
+        <th class="px-2 py-1">Customer</th>
141
+        <th class="px-2 py-1 text-right">Total</th>
142
+        <th class="px-2 py-1 text-right">Paid</th>
143
+        <th class="px-2 py-1 text-right">Balance</th>
144
+        <th class="px-2 py-1">Status</th>
145
+        <th class="px-2 py-1">Issued</th>
146
+        <th class="px-2 py-1">Actions</th>
147
+      </tr>
148
+    </thead>
149
+    <tbody class="divide-y">
150
+      {% for inv in invoices %}
151
+      <tr>
152
+        <td class="px-2 py-1"><a class="text-blue-700 hover:underline" href="{% url 'billing:invoice_detail' inv.id %}">#{{ inv.id }}</a></td>
153
+        <td class="px-2 py-1">{{ inv.customer.name }}</td>
154
+        <td class="px-2 py-1 text-right">{{ inv.total_amount|floatformat:2|intcomma }} {{ inv.currency_code }}</td>
155
+        <td class="px-2 py-1 text-right">{{ inv.payments_total|default:0|floatformat:2|intcomma }} {{ inv.currency_code }}</td>
156
+        <td class="px-2 py-1 text-right">{{ inv.balance|floatformat:2|intcomma }} {{ inv.currency_code }}</td>
157
+        <td class="px-2 py-1"><span class="px-2 py-1 rounded bg-gray-100 text-gray-700">{{ inv.status }}</span></td>
158
+        <td class="px-2 py-1">{{ inv.issued_at|default:"-" }}</td>
159
+        <td class="px-2 py-1">
160
+          <div class="flex items-center gap-2">
161
+            <a class="btn-outline btn-xs" href="{% url 'billing:invoice_detail' inv.id %}">View</a>
162
+            {% if inv.status == 'draft' %}
163
+            <form method="post" action="{% url 'recycle_core:pickup_regenerate_invoice' pickup.id %}">
164
+              {% csrf_token %}
165
+              <button class="btn btn-xs" onclick="return confirm('Regenerate draft invoice from current tickets?')">Regenerate</button>
166
+            </form>
167
+            {% endif %}
168
+          </div>
169
+        </td>
170
+      </tr>
171
+      {% empty %}
172
+      <tr>
173
+        <td class="px-2 py-2 text-gray-500" colspan="8">No invoices for this pickup.</td>
174
+      </tr>
175
+      {% endfor %}
176
+    </tbody>
177
+  </table>
178
+  </div>
105 179
 </div>
106 180
 
107 181
 {% endblock %}

+ 14 - 12
recycle_core/templates/recycle_core/pickups_list.html

@@ -58,19 +58,21 @@
58 58
               <button class="btn btn-xs">Update</button>
59 59
             </form>
60 60
             {% endif %}
61
-            {% if p.weigh_ticket %}
62
-              {% if perms.recycle_core.generate_invoice %}
63
-              <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' p.id %}">
64
-                {% csrf_token %}
65
-                <button class="btn btn-xs">Generate Invoice</button>
66
-              </form>
67
-              {% endif %}
68
-              <a class="btn-outline btn-xs" href="{% url 'recycle_core:weigh_ticket_detail' p.weigh_ticket.id %}">View Ticket</a>
69
-            {% else %}
70
-              {% if perms.recycle_core.create_weigh_ticket %}
71
-              <a class="btn btn-xs" href="{% url 'recycle_core:pickup_create_weigh_ticket' p.id %}">Create Weigh Ticket</a>
72
-              {% endif %}
61
+            {% if perms.recycle_core.generate_invoice %}
62
+            <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' p.id %}">
63
+              {% csrf_token %}
64
+              <button class="btn btn-xs">Generate Invoice</button>
65
+            </form>
66
+            {% endif %}
67
+            {% if perms.recycle_core.create_weigh_ticket %}
68
+            <a class="btn btn-xs" href="{% url 'recycle_core:pickup_create_weigh_ticket' p.id %}">Create Weigh Ticket</a>
73 69
             {% endif %}
70
+            {% with count=p.weigh_tickets.count %}
71
+              {% if count %}
72
+                <span class="text-xs text-gray-600">{{ count }} ticket{{ count|pluralize }}</span>
73
+                <a class="btn-outline btn-xs" href="{% url 'recycle_core:pickup_detail' p.id %}#tickets">View</a>
74
+              {% endif %}
75
+            {% endwith %}
74 76
           </div>
75 77
         </td>
76 78
       </tr>

+ 6 - 0
recycle_core/templates/recycle_core/user_form.html

@@ -57,6 +57,12 @@
57 57
         {% endif %}
58 58
       </div>
59 59
     {% endif %}
60
+    {% if form_extras %}
61
+    <div class="mt-4">
62
+      <h3 class="text-sm font-semibold mb-2">Contact & Address</h3>
63
+      {{ form_extras|crispy }}
64
+    </div>
65
+    {% endif %}
60 66
     {% if form_photo %}
61 67
     <div class="mt-4">
62 68
       <h3 class="text-sm font-semibold mb-2">Profile Photo</h3>

+ 149 - 11
recycle_core/templates/recycle_core/weigh_ticket_detail.html

@@ -1,5 +1,5 @@
1 1
 {% extends "admin_frontend/base.html" %}
2
-{% load breadcrumbs %}
2
+{% load breadcrumbs widget_tweaks humanize %}
3 3
 {% block title %}Weigh Ticket {{ ticket.ticket_number|default:ticket.id }}{% endblock %}
4 4
 {% block content %}
5 5
 {% render_breadcrumbs breadcrumbs %}
@@ -13,37 +13,151 @@
13 13
       <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
14 14
     </form>
15 15
     {% endif %}
16
+    <form method="post" action="{% url 'recycle_core:weigh_ticket_recalc_carbon' ticket.id %}">
17
+      {% csrf_token %}
18
+      <button class="px-3 py-2 rounded bg-gray-700 text-white">Recalculate Carbon</button>
19
+    </form>
16 20
   </div>
17 21
 </div>
18 22
 
19
-<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
20
-  <div class="bg-white rounded shadow p-4">
23
+<div class="grid grid-cols-1 md:grid-cols-10 gap-6 mb-6">
24
+  <div class="bg-white rounded shadow p-4 md:col-span-3">
21 25
     <h2 class="font-medium mb-2">Details</h2>
22 26
     <dl class="grid grid-cols-3 gap-2 text-sm">
23 27
       <dt class="text-gray-500">Pickup</dt><dd class="col-span-2"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:pickup_detail' ticket.pickup.id %}">#{{ ticket.pickup.id }}</a></dd>
24 28
       <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ ticket.pickup.customer.name }}</dd>
25 29
       <dt class="text-gray-500">Recorded</dt><dd class="col-span-2">{{ ticket.recorded_at }}</dd>
26
-      <dt class="text-gray-500">Gross</dt><dd class="col-span-2">{{ ticket.gross_weight }} {{ ticket.unit }}</dd>
27
-      <dt class="text-gray-500">Tare</dt><dd class="col-span-2">{{ ticket.tare_weight }} {{ ticket.unit }}</dd>
28
-      <dt class="text-gray-500">Net</dt><dd class="col-span-2">{{ ticket.net_weight }} {{ ticket.unit }}</dd>
30
+      <dt class="text-gray-500">Gross</dt><dd class="col-span-2 text-right">{{ ticket.gross_weight|floatformat:3|intcomma }} {{ ticket.unit }}</dd>
31
+      <dt class="text-gray-500">Tare</dt><dd class="col-span-2 text-right">{{ ticket.tare_weight|floatformat:3|intcomma }} {{ ticket.unit }}</dd>
32
+      <dt class="text-gray-500">Net</dt><dd class="col-span-2 text-right">{{ ticket.net_weight|floatformat:3|intcomma }} {{ ticket.unit }}</dd>
33
+      <dt class="text-gray-500">Finalized</dt><dd class="col-span-2">{% if is_finalized %}Yes{% else %}No{% endif %}</dd>
29 34
     </dl>
35
+    {% if not is_finalized %}
36
+    <div class="mt-4 space-y-2">
37
+      {% if allow_adjust_net %}
38
+      <form method="post">
39
+        {% csrf_token %}
40
+        <input type="hidden" name="action" value="adjust_net_to_lines">
41
+        <button class="px-3 py-2 rounded bg-gray-800 text-white w-full">Adjust Net to Lines</button>
42
+      </form>
43
+      {% endif %}
44
+      {% if allow_residual %}
45
+      <form method="post">
46
+        {% csrf_token %}
47
+        <input type="hidden" name="action" value="add_residual_line">
48
+        <button class="px-3 py-2 rounded bg-gray-700 text-white w-full">Add Residual Line</button>
49
+      </form>
50
+      {% endif %}
51
+      <form method="post">
52
+        {% csrf_token %}
53
+        <input type="hidden" name="action" value="finalize_ticket">
54
+        <button class="px-3 py-2 rounded bg-blue-600 text-white w-full">Finalize Ticket</button>
55
+      </form>
56
+    </div>
57
+    {% endif %}
58
+    {% if perms.recycle_core.edit_weigh_ticket and not is_finalized %}
59
+      <div class="mt-6 border-t pt-4">
60
+        <button type="button" class="px-3 py-2 rounded bg-emerald-600 text-white w-full" onclick="document.getElementById('edit-weights-modal').classList.remove('hidden')">Edit Weights</button>
61
+      </div>
62
+      <!-- Modal -->
63
+      <div id="edit-weights-modal" class="hidden fixed inset-0 z-50">
64
+        <div class="absolute inset-0 bg-black/50" onclick="document.getElementById('edit-weights-modal').classList.add('hidden')"></div>
65
+        <div class="relative max-w-md w-full mx-auto mt-20 bg-white rounded shadow p-4">
66
+          <div class="flex items-center justify-between mb-2">
67
+            <h3 class="font-medium">Edit Weights</h3>
68
+            <button type="button" class="text-gray-600 hover:text-gray-800" onclick="document.getElementById('edit-weights-modal').classList.add('hidden')">✕</button>
69
+          </div>
70
+          <form method="post" class="grid grid-cols-1 gap-3">
71
+            {% csrf_token %}
72
+            <input type="hidden" name="action" value="edit_weights">
73
+            {% if weights_form.non_field_errors %}
74
+              <div class="text-red-700 text-sm">{{ weights_form.non_field_errors }}</div>
75
+            {% endif %}
76
+            <div>
77
+              <label class="block text-sm text-gray-600 mb-1">Gross</label>
78
+              {{ weights_form.gross_weight|attr:"class:w-full max-w-full text-right" }}
79
+              {% if weights_form.gross_weight.errors %}
80
+                <div class="text-red-700 text-xs mt-1">{{ weights_form.gross_weight.errors|striptags }}</div>
81
+              {% endif %}
82
+            </div>
83
+            <div>
84
+              <label class="block text-sm text-gray-600 mb-1">Tare</label>
85
+              {{ weights_form.tare_weight|attr:"class:w-full max-w-full text-right" }}
86
+              {% if weights_form.tare_weight.errors %}
87
+                <div class="text-red-700 text-xs mt-1">{{ weights_form.tare_weight.errors|striptags }}</div>
88
+              {% endif %}
89
+            </div>
90
+            <div>
91
+              <label class="block text-sm text-gray-600 mb-1">Unit</label>
92
+              {% if lines %}
93
+                <div title="Cannot change unit while lines exist">
94
+                  {{ weights_form.unit|attr:"class:w-full max-w-full"|attr:"disabled:disabled" }}
95
+                  <input type="hidden" name="unit" value="{{ weights_form.unit.value|default:ticket.unit }}" />
96
+                </div>
97
+              {% else %}
98
+                  {{ weights_form.unit|attr:"class:w-full max-w-full" }}
99
+              {% endif %}
100
+              {% if weights_form.unit.errors %}
101
+                <div class="text-red-700 text-xs mt-1">{{ weights_form.unit.errors|striptags }}</div>
102
+              {% endif %}
103
+            </div>
104
+            <div class="flex items-center justify-end gap-2 pt-1">
105
+              <button type="button" class="px-3 py-2 rounded border border-gray-300 text-gray-700" onclick="document.getElementById('edit-weights-modal').classList.add('hidden')">Cancel</button>
106
+              <button class="px-3 py-2 rounded bg-emerald-600 text-white">Save</button>
107
+            </div>
108
+          </form>
109
+        </div>
110
+      </div>
111
+    {% endif %}
30 112
   </div>
31
-  <div class="bg-white rounded shadow p-4">
113
+  <div class="bg-white rounded shadow p-4 md:col-span-7">
32 114
     <h2 class="font-medium mb-2">Lines</h2>
115
+    <div class="text-sm mb-3">
116
+      <div>
117
+        <span class="text-gray-600">Net Weight:</span>
118
+        <span class="font-medium">{{ ticket.net_weight }} {{ ticket.unit }}</span>
119
+      </div>
120
+      <div>
121
+        <span class="text-gray-600">Sum of Lines ({{ ticket.unit }}):</span>
122
+        <span class="font-medium">{{ lines_sum }} {{ ticket.unit }}</span>
123
+        {% if has_unconvertible %}
124
+          <span class="ml-2 text-xs text-amber-700">Includes unconverted items (different units)</span>
125
+        {% endif %}
126
+      </div>
127
+      <div>
128
+        <span class="text-gray-600">Difference:</span>
129
+        <span class="font-medium {% if lines_delta != 0 %}text-red-700{% endif %}">{{ lines_delta }} {{ ticket.unit }}</span>
130
+      </div>
131
+    </div>
33 132
     <table class="min-w-full">
34 133
       <thead>
35 134
         <tr class="text-left text-sm text-gray-600">
36 135
           <th class="px-2 py-1">Material</th>
37
-          <th class="px-2 py-1">Qty</th>
136
+          <th class="px-2 py-1 text-right">Qty</th>
38 137
           <th class="px-2 py-1">Unit</th>
138
+          <th class="px-2 py-1">Actions</th>
39 139
         </tr>
40 140
       </thead>
41 141
       <tbody class="divide-y">
42 142
         {% for line in lines %}
43 143
           <tr>
44
-            <td class="px-2 py-1">{{ line.material.name }}</td>
45
-            <td class="px-2 py-1">{{ line.quantity }}</td>
46
-            <td class="px-2 py-1">{{ line.unit }}</td>
144
+            <form method="post">
145
+              {% csrf_token %}
146
+              <input type="hidden" name="action" value="edit">
147
+              <input type="hidden" name="line_id" value="{{ line.id }}">
148
+              <td class="px-2 py-1">{{ line.form.material|attr:"class:w-full max-w-full" }}</td>
149
+              <td class="px-2 py-1 text-right">{{ line.form.quantity|attr:"class:w-full max-w-full text-right" }}</td>
150
+              <td class="px-2 py-1">{{ line.form.unit|attr:"class:w-full max-w-full" }}</td>
151
+              <td class="px-2 py-1">
152
+                <button class="px-2 py-1 rounded bg-blue-600 text-white text-sm">Save</button>
153
+                <button form="del-line-{{ line.id }}" class="px-2 py-1 rounded bg-red-600 text-white text-sm" onclick="return confirm('Delete this line?')">Delete</button>
154
+              </td>
155
+            </form>
156
+            <form id="del-line-{{ line.id }}" method="post" class="hidden">
157
+              {% csrf_token %}
158
+              <input type="hidden" name="action" value="delete">
159
+              <input type="hidden" name="line_id" value="{{ line.id }}">
160
+            </form>
47 161
           </tr>
48 162
         {% empty %}
49 163
           <tr>
@@ -52,6 +166,30 @@
52 166
         {% endfor %}
53 167
       </tbody>
54 168
     </table>
169
+    <div class="mt-4 border-t pt-4">
170
+      <h3 class="font-medium mb-2">Add Line</h3>
171
+      <form method="post">
172
+        {% csrf_token %}
173
+        <input type="hidden" name="action" value="add">
174
+        <div class="grid grid-cols-1 md:grid-cols-4 gap-4 md:gap-6 items-end">
175
+          <div>
176
+            <label class="block text-sm text-gray-600 mb-1">Material</label>
177
+            {{ add_line_form.material|attr:"class:w-full max-w-full" }}
178
+          </div>
179
+          <div>
180
+            <label class="block text-sm text-gray-600 mb-1">Quantity</label>
181
+            {{ add_line_form.quantity|attr:"class:w-full max-w-full text-right" }}
182
+          </div>
183
+          <div>
184
+            <label class="block text-sm text-gray-600 mb-1">Unit</label>
185
+            {{ add_line_form.unit|attr:"class:w-full max-w-full" }}
186
+          </div>
187
+          <div>
188
+            <button class="px-3 py-2 rounded bg-blue-600 text-white">Add</button>
189
+          </div>
190
+        </div>
191
+      </form>
192
+    </div>
55 193
   </div>
56 194
 </div>
57 195
 

+ 10 - 0
recycle_core/urls.py

@@ -22,6 +22,7 @@ urlpatterns = [
22 22
     path("pickups/<int:pk>/assign/", views.pickup_assign, name="pickup_assign"),
23 23
     path("pickups/<int:pk>/set-status/", views.pickup_set_status, name="pickup_set_status"),
24 24
     path("pickups/<int:pk>/generate-invoice/", views.pickup_generate_invoice, name="pickup_generate_invoice"),
25
+    path("pickups/<int:pk>/regenerate-invoice/", views.pickup_regenerate_invoice, name="pickup_regenerate_invoice"),
25 26
     path("pickups/<int:pk>/create-weigh-ticket/", views.pickup_create_weigh_ticket, name="pickup_create_weigh_ticket"),
26 27
     path("pickups/<int:pk>/", views.pickup_detail, name="pickup_detail"),
27 28
 
@@ -33,6 +34,7 @@ urlpatterns = [
33 34
     path("weigh-tickets/", views.weigh_tickets_list, name="weigh_tickets_list"),
34 35
     path("weigh-tickets/<int:pk>/", views.weigh_ticket_detail, name="weigh_ticket_detail"),
35 36
     path("weigh-tickets/<int:pk>/generate-invoice/", views.weigh_ticket_generate_invoice, name="weigh_ticket_generate_invoice"),
37
+    path("weigh-tickets/<int:pk>/recalc-carbon/", views.weigh_ticket_recalc_carbon, name="weigh_ticket_recalc_carbon"),
36 38
 
37 39
 
38 40
     path("documents/", views.documents_list, name="documents_list"),
@@ -45,4 +47,12 @@ urlpatterns = [
45 47
     path("services/<int:pk>/delete/", views.service_delete, name="service_delete"),
46 48
     path("services/<int:pk>/toggle-enabled/", views.service_toggle_enabled, name="service_toggle_enabled"),
47 49
     path("services/reorder/", views.services_reorder, name="services_reorder"),
50
+
51
+    # Carbon approvals
52
+    path("carbon-events/", views.carbon_events_list, name="carbon_events_list"),
53
+
54
+    # Emission factors
55
+    path("emission-factors/", views.emission_factors_list, name="emission_factors_list"),
56
+    path("emission-factors/<int:pk>/edit/", views.emission_factor_edit, name="emission_factor_edit"),
57
+    path("emission-factors/<int:pk>/delete/", views.emission_factor_delete, name="emission_factor_delete"),
48 58
 ]

+ 531 - 14
recycle_core/views.py

@@ -20,6 +20,9 @@ from .models import (
20 20
     ProvidedService,
21 21
     Customer,
22 22
     CustomerSite,
23
+    PickupItem,
24
+    PriceList,
25
+    PriceListItem,
23 26
     PickupOrder,
24 27
     WeighTicket,
25 28
     WeighLine,
@@ -27,6 +30,9 @@ from .models import (
27 30
     ScrapBid,
28 31
     Document,
29 32
     AuditLog,
33
+    CarbonEvent,
34
+    CarbonBalance,
35
+    MaterialEmissionFactor,
30 36
 )
31 37
 from .forms import (
32 38
     MaterialForm,
@@ -37,8 +43,14 @@ from .forms import (
37 43
     DocumentForm,
38 44
     ProvidedServiceForm,
39 45
     WeighTicketForm,
46
+    WeighLineForm,
47
+    WeighTicketWeightsForm,
48
+    MaterialEmissionFactorForm,
49
+    PickupItemForm,
40 50
 )
41
-from .services.billing import generate_invoice_for_pickup
51
+from .services.billing import generate_invoice_for_pickup, regenerate_invoice_for_pickup
52
+from billing.models import Invoice
53
+from .services import carbon as carbon_svc
42 54
 from django.contrib.contenttypes.models import ContentType
43 55
 from django.contrib.auth import get_user_model
44 56
 from django.http import HttpResponse, JsonResponse
@@ -226,11 +238,26 @@ def org_user_edit(request, pk: int):
226 238
         "is_active": user.is_active,
227 239
     })
228 240
     form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
229
-    print(form_photo)
241
+    # Extra contact/address fields on the profile
242
+    from django.forms import modelform_factory
243
+    ExtraForm = modelform_factory(UserProfile, fields=(
244
+        "phone",
245
+        "job_title",
246
+        "department",
247
+        "preferred_language",
248
+        "address_line1",
249
+        "address_line2",
250
+        "city",
251
+        "state",
252
+        "postal_code",
253
+        "country",
254
+    ))
255
+    form_extras = ExtraForm(request.POST or None, instance=profile) if profile else None
230 256
     if request.method == "POST":
231 257
         ok_main = form.is_valid()
232 258
         ok_photo = True if form_photo is None else form_photo.is_valid()
233
-        if ok_main and ok_photo:
259
+        ok_extras = True if form_extras is None else form_extras.is_valid()
260
+        if ok_main and ok_photo and ok_extras:
234 261
             user.email = form.cleaned_data.get("email") or ""
235 262
             user.first_name = form.cleaned_data.get("first_name") or ""
236 263
             user.last_name = form.cleaned_data.get("last_name") or ""
@@ -252,14 +279,16 @@ def org_user_edit(request, pk: int):
252 279
             if form_photo is not None:
253 280
                 form_photo.instance = profile
254 281
                 form_photo.save()
282
+            if form_extras is not None:
283
+                form_extras.instance = profile
284
+                form_extras.save()
255 285
             messages.success(request, f"User '{user.username}' updated.")
256 286
             next_url = request.POST.get("next") or request.GET.get("next")
257 287
             if next_url:
258 288
                 return redirect(next_url)
259 289
             return redirect("recycle_core:org_users_list")
260 290
         messages.error(request, "Please correct the errors below.")
261
-    print(f"fp = {form_photo}")
262
-    return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
291
+    return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "form_extras": form_extras, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
263 292
 
264 293
 
265 294
 @owner_required
@@ -534,10 +563,76 @@ def pickup_detail(request, pk: int):
534 563
         PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
535 564
     )
536 565
     items = pickup.items.select_related("material").all()
566
+    add_item_form = None
567
+    if request.method == "POST":
568
+        action = request.POST.get("action")
569
+        if action == "add_item":
570
+            add_item_form = PickupItemForm(request.POST, organization=pickup.organization)
571
+            if add_item_form.is_valid():
572
+                PickupItem.objects.create(
573
+                    pickup=pickup,
574
+                    material=add_item_form.cleaned_data["material"],
575
+                    estimated_qty=add_item_form.cleaned_data.get("estimated_qty"),
576
+                    unit=add_item_form.cleaned_data["unit"],
577
+                )
578
+                messages.success(request, "Pickup item added.")
579
+                return redirect("recycle_core:pickup_detail", pk=pickup.id)
580
+            else:
581
+                messages.error(request, "Please correct the item form errors.")
582
+    if add_item_form is None:
583
+        add_item_form = PickupItemForm(organization=pickup.organization)
584
+    # Invoices associated with this pickup (with payment totals/balance)
585
+    inv_qs = (
586
+        Invoice.objects.select_related("customer")
587
+        .filter(pickup=pickup)
588
+        .annotate(
589
+            payments_total=Coalesce(
590
+                Sum("payments__amount"),
591
+                Value(Decimal("0.00")),
592
+                output_field=DecimalField(max_digits=14, decimal_places=2),
593
+            )
594
+        )
595
+        .annotate(
596
+            balance=ExpressionWrapper(
597
+                F("total_amount") - F("payments_total"),
598
+                output_field=DecimalField(max_digits=14, decimal_places=2),
599
+            )
600
+        )
601
+        .order_by("-issued_at", "-id")
602
+    )
603
+
604
+    # Compute per-ticket pricing summary (unit price avg and total)
605
+    # Attach pricing summaries on each ticket
606
+    price_list = pickup.customer.price_list or PriceList.objects.filter(organization=pickup.organization).order_by("id").first()
607
+    currency_code = getattr(price_list, "currency_code", None) or pickup.organization.currency_code
608
+    for t in pickup.weigh_tickets.all():
609
+        total = Decimal("0.00")
610
+        for ln in t.lines.all():
611
+            pli = (
612
+                PriceListItem.objects.filter(
613
+                    price_list=price_list, material_id=ln.material_id, unit=ln.unit, direction=PriceListItem.DIRECTION_SELL
614
+                ).first()
615
+                if price_list
616
+                else None
617
+            )
618
+            if pli and pli.unit_price and Decimal(pli.unit_price) > 0:
619
+                total += (Decimal(ln.quantity) * Decimal(pli.unit_price)).quantize(Decimal("0.01"))
620
+        avg_unit = None
621
+        try:
622
+            if total > 0 and t.net_weight:
623
+                avg_unit = (total / Decimal(t.net_weight)).quantize(Decimal("0.01"))
624
+        except Exception:
625
+            avg_unit = None
626
+        t.total_value = total
627
+        t.avg_unit = avg_unit
628
+
537 629
     context = {
538 630
         "nav": _nav_items(),
539 631
         "pickup": pickup,
540 632
         "items": items,
633
+        "add_item_form": add_item_form,
634
+        "invoices": list(inv_qs),
635
+        "ticket_currency": currency_code,
541 636
     }
542 637
     return render(request, "recycle_core/pickup_detail.html", context)
543 638
 
@@ -546,9 +641,6 @@ def pickup_detail(request, pk: int):
546 641
 @permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
547 642
 def pickup_create_weigh_ticket(request, pk: int):
548 643
     pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
549
-    if hasattr(pickup, "weigh_ticket"):
550
-        messages.info(request, "Weigh ticket already exists for this pickup.")
551
-        return redirect("recycle_core:weigh_ticket_detail", pk=pickup.weigh_ticket.id)
552 644
 
553 645
     if request.method == "POST":
554 646
         form = WeighTicketForm(request.POST)
@@ -584,14 +676,27 @@ def pickup_create_weigh_ticket(request, pk: int):
584 676
 def pickup_generate_invoice(request, pk: int):
585 677
     pickup = get_object_or_404(PickupOrder, pk=pk)
586 678
     try:
587
-        if not hasattr(pickup, "weigh_ticket"):
588
-            messages.error(request, "Pickup has no weigh ticket.")
589
-            return redirect("recycle_core:pickups_list")
679
+        if not pickup.weigh_tickets.exists():
680
+            messages.error(request, "Pickup has no weigh tickets.")
681
+            return redirect("recycle_core:pickup_detail", pk=pickup.id)
590 682
         invoice = generate_invoice_for_pickup(pickup)
591 683
         messages.success(request, f"Invoice #{invoice.id} generated.")
592 684
     except Exception as e:
593 685
         messages.error(request, f"Failed to generate invoice: {e}")
594
-    return redirect("recycle_core:pickups_list")
686
+    return redirect("recycle_core:pickup_detail", pk=pickup.id)
687
+
688
+
689
+@staff_member_required
690
+@permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
691
+@require_POST
692
+def pickup_regenerate_invoice(request, pk: int):
693
+    pickup = get_object_or_404(PickupOrder, pk=pk)
694
+    try:
695
+        inv = regenerate_invoice_for_pickup(pickup)
696
+        messages.success(request, f"Invoice #{inv.id} regenerated from current tickets.")
697
+    except Exception as e:
698
+        messages.error(request, f"Failed to regenerate invoice: {e}")
699
+    return redirect("recycle_core:pickup_detail", pk=pickup.id)
595 700
 
596 701
 
597 702
 # Scrap Listings UI ----------------------------------------------------------
@@ -738,17 +843,250 @@ def weigh_tickets_list(request):
738 843
 @staff_member_required
739 844
 @breadcrumbs(label="Ticket Detail", parent="re_tickets")
740 845
 def weigh_ticket_detail(request, pk: int):
741
-    ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer"), pk=pk)
846
+    ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization"), pk=pk)
742 847
     lines = WeighLine.objects.select_related("material").filter(ticket=ticket)
848
+    # Add WeighLine inline form
849
+    add_line_form = None
850
+    weights_form = None
851
+    if request.method == "POST":
852
+        action = request.POST.get("action", "add")
853
+        org = ticket.pickup.organization
854
+        if action == "add":
855
+            add_line_form = WeighLineForm(request.POST, organization=org)
856
+            if add_line_form.is_valid():
857
+                WeighLine.objects.create(
858
+                    ticket=ticket,
859
+                    material=add_line_form.cleaned_data["material"],
860
+                    quantity=add_line_form.cleaned_data["quantity"],
861
+                    unit=add_line_form.cleaned_data["unit"],
862
+                )
863
+                messages.success(request, "Weigh line added.")
864
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
865
+            else:
866
+                messages.error(request, "Please correct the line form errors.")
867
+        elif action == "edit":
868
+            line_id = request.POST.get("line_id")
869
+            line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
870
+            form = WeighLineForm(request.POST, organization=org)
871
+            if form.is_valid():
872
+                line.material = form.cleaned_data["material"]
873
+                line.quantity = form.cleaned_data["quantity"]
874
+                line.unit = form.cleaned_data["unit"]
875
+                line.save()
876
+                messages.success(request, f"Line #{line.id} updated.")
877
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
878
+            else:
879
+                messages.error(request, "Please correct the line form errors.")
880
+        elif action == "delete":
881
+            line_id = request.POST.get("line_id")
882
+            line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
883
+            try:
884
+                # Also remove any linked carbon event(s)
885
+                CarbonEvent.objects.filter(weigh_line=line).delete()
886
+            except Exception:
887
+                pass
888
+            line.delete()
889
+            messages.success(request, "Weigh line deleted.")
890
+            return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
891
+        elif action == "adjust_net_to_lines":
892
+            if not org.allow_adjust_net_to_lines:
893
+                messages.error(request, "Policy does not allow adjusting net to lines.")
894
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
895
+            # Recalculate sum in ticket unit (computed below again for display)
896
+            LB_TO_KG = Decimal("0.45359237")
897
+            def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
898
+                if from_unit == to_unit:
899
+                    return qty
900
+                if from_unit == 'lb' and to_unit == 'kg':
901
+                    return (qty * LB_TO_KG).quantize(Decimal('0.000'))
902
+                if from_unit == 'kg' and to_unit == 'lb':
903
+                    return (qty / LB_TO_KG).quantize(Decimal('0.000'))
904
+                return Decimal('0')
905
+            total_lines = Decimal('0.000')
906
+            for ln in lines:
907
+                if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
908
+                    total_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
909
+            ticket.net_weight = total_lines
910
+            ticket.save(update_fields=["net_weight", "updated_at"])
911
+            messages.success(request, "Net weight adjusted to sum of lines.")
912
+            return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
913
+        elif action == "add_residual_line":
914
+            if not org.allow_add_residual_line:
915
+                messages.error(request, "Policy does not allow adding residual line.")
916
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
917
+            # Compute residual quantity = net - sum(lines in ticket unit)
918
+            LB_TO_KG = Decimal("0.45359237")
919
+            def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
920
+                if from_unit == to_unit:
921
+                    return qty
922
+                if from_unit == 'lb' and to_unit == 'kg':
923
+                    return (qty * LB_TO_KG).quantize(Decimal('0.000'))
924
+                if from_unit == 'kg' and to_unit == 'lb':
925
+                    return (qty / LB_TO_KG).quantize(Decimal('0.000'))
926
+                return Decimal('0')
927
+            sum_lines = Decimal('0.000')
928
+            for ln in lines:
929
+                if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
930
+                    sum_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
931
+            resid = (Decimal(ticket.net_weight) - sum_lines).quantize(Decimal('0.000'))
932
+            if resid == 0:
933
+                messages.info(request, "No residual difference to add.")
934
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
935
+            # Ensure residual material exists for the org
936
+            residual_name = "Residual"
937
+            residual = Material.objects.filter(organization=org, name=residual_name).first()
938
+            if not residual:
939
+                residual = Material.objects.create(
940
+                    organization=org,
941
+                    category="Mixed",
942
+                    name=residual_name,
943
+                    default_unit=ticket.unit,
944
+                )
945
+            WeighLine.objects.create(ticket=ticket, material=residual, quantity=resid, unit=ticket.unit)
946
+            messages.success(request, f"Residual line of {resid} {ticket.unit} added.")
947
+            return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
948
+        elif action == "finalize_ticket":
949
+            # Validate tolerance and finalize
950
+            LB_TO_KG = Decimal("0.45359237")
951
+            def _to_kg(qty: Decimal, unit: str) -> Decimal:
952
+                if unit == 'kg':
953
+                    return qty
954
+                if unit == 'lb':
955
+                    return (qty * LB_TO_KG).quantize(Decimal('0.000'))
956
+                return Decimal('0')
957
+            # compute delta in kg
958
+            sum_lines_ticket = Decimal('0.000')
959
+            for ln in lines:
960
+                if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
961
+                    # convert line to ticket unit
962
+                    if ln.unit == ticket.unit:
963
+                        sum_lines_ticket += Decimal(ln.quantity)
964
+                    else:
965
+                        sum_lines_ticket += (Decimal(ln.quantity) * (LB_TO_KG if ln.unit=='lb' and ticket.unit=='kg' else Decimal('1')/LB_TO_KG)).quantize(Decimal('0.000'))
966
+            delta_ticket = (Decimal(ticket.net_weight) - sum_lines_ticket)
967
+            delta_kg = _to_kg(delta_ticket, ticket.unit)
968
+            net_kg = _to_kg(Decimal(ticket.net_weight), ticket.unit)
969
+            abs_tol_kg = org.ticket_tolerance_abs_kg
970
+            pct_tol_kg = (net_kg * (org.ticket_tolerance_pct / Decimal('100'))).quantize(Decimal('0.000')) if net_kg else Decimal('0.000')
971
+            tol_kg = abs_tol_kg if abs_tol_kg >= pct_tol_kg else pct_tol_kg
972
+            if delta_kg.copy_abs() > tol_kg:
973
+                messages.error(request, f"Cannot finalize: difference {delta_kg} kg exceeds tolerance {tol_kg} kg.")
974
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
975
+            ticket.is_finalized = True
976
+            ticket.finalized_by = request.user
977
+            from django.utils import timezone as djtz
978
+            ticket.finalized_at = djtz.now()
979
+            ticket.save(update_fields=["is_finalized", "finalized_by", "finalized_at", "updated_at"])
980
+            messages.success(request, "Ticket finalized.")
981
+            return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
982
+        elif action == "edit_weights":
983
+            # Permission and state checks
984
+            if not request.user.has_perm('recycle_core.edit_weigh_ticket'):
985
+                messages.error(request, "You are not allowed to edit weights.")
986
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
987
+            if ticket.is_finalized:
988
+                messages.error(request, "Ticket is finalized and cannot be edited.")
989
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
990
+            wform = WeighTicketWeightsForm(request.POST)
991
+            if wform.is_valid():
992
+                gross = wform.cleaned_data['gross_weight']
993
+                tare = wform.cleaned_data['tare_weight']
994
+                unit = wform.cleaned_data['unit']
995
+                # Disallow unit change if lines exist
996
+                if unit != ticket.unit and lines.exists():
997
+                    messages.error(request, "Cannot change unit when lines exist. Remove lines first or keep unit.")
998
+                    return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
999
+                old = {
1000
+                    'gross': str(ticket.gross_weight),
1001
+                    'tare': str(ticket.tare_weight),
1002
+                    'net': str(ticket.net_weight),
1003
+                    'unit': ticket.unit,
1004
+                }
1005
+                ticket.gross_weight = gross
1006
+                ticket.tare_weight = tare
1007
+                ticket.net_weight = (Decimal(gross) - Decimal(tare)).quantize(Decimal('0.000'))
1008
+                ticket.unit = unit
1009
+                ticket.save(update_fields=["gross_weight", "tare_weight", "net_weight", "unit", "updated_at"])
1010
+                try:
1011
+                    from .services.audit import log_action
1012
+                    log_action(
1013
+                        organization=org,
1014
+                        user=request.user,
1015
+                        action="weigh_ticket.update_weights",
1016
+                        obj=ticket,
1017
+                        metadata={"before": old, "after": {"gross": str(gross), "tare": str(tare), "net": str(ticket.net_weight), "unit": unit}},
1018
+                    )
1019
+                except Exception:
1020
+                    pass
1021
+                messages.success(request, "Weights updated.")
1022
+                return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
1023
+            else:
1024
+                messages.error(request, "Please correct the weights form errors.")
1025
+                weights_form = wform  # keep field errors for rendering
1026
+    else:
1027
+        add_line_form = WeighLineForm(organization=ticket.pickup.organization, initial={"unit": ticket.unit})
743 1028
     ct = ContentType.objects.get_for_model(WeighTicket)
744 1029
     docs = Document.objects.filter(content_type=ct, object_id=ticket.id)
1030
+    # Forms for editing existing lines (attach to each line)
1031
+    for ln in lines:
1032
+        ln.form = WeighLineForm(
1033
+            organization=ticket.pickup.organization,
1034
+            initial={"material": ln.material_id, "quantity": ln.quantity, "unit": ln.unit},
1035
+        )
1036
+
1037
+    # Compute lines total converted to ticket unit and delta to net weight
1038
+    LB_TO_KG = Decimal("0.45359237")
1039
+    def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
1040
+        if from_unit == to_unit:
1041
+            return qty
1042
+        if from_unit == 'lb' and to_unit == 'kg':
1043
+            return (qty * LB_TO_KG).quantize(Decimal('0.000'))
1044
+        if from_unit == 'kg' and to_unit == 'lb':
1045
+            return (qty / LB_TO_KG).quantize(Decimal('0.000'))
1046
+        # pcs or unknown: cannot convert; return 0 so we don't skew sums
1047
+        return Decimal('0')
1048
+
1049
+    sum_in_ticket_unit = Decimal('0.000')
1050
+    has_unconvertible = False
1051
+    for ln in lines:
1052
+        if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
1053
+            sum_in_ticket_unit += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
1054
+        else:
1055
+            # pcs or mixed with pcs — flag as unconvertible
1056
+            if ln.unit != ticket.unit:
1057
+                has_unconvertible = True
1058
+            else:
1059
+                # same unit (e.g., pcs) — include as-is
1060
+                sum_in_ticket_unit += Decimal(ln.quantity)
1061
+    try:
1062
+        net = Decimal(ticket.net_weight)
1063
+    except Exception:
1064
+        net = Decimal('0.000')
1065
+    delta = (net - sum_in_ticket_unit).quantize(Decimal('0.000'))
1066
+
1067
+    # Prepare weights edit form (pre-filled) when not coming from a failed POST
1068
+    if weights_form is None:
1069
+        weights_form = WeighTicketWeightsForm(initial={
1070
+            'gross_weight': ticket.gross_weight,
1071
+            'tare_weight': ticket.tare_weight,
1072
+            'unit': ticket.unit,
1073
+        })
1074
+
745 1075
     context = {
746 1076
         "nav": _nav_items(),
747 1077
         "ticket": ticket,
748 1078
         "lines": lines,
749 1079
         "generate_url": reverse("recycle_core:weigh_ticket_generate_invoice", args=[ticket.id]),
1080
+        "add_line_form": add_line_form,
1081
+        "weights_form": weights_form,
750 1082
         "documents": docs,
751 1083
         "ct_id": ct.id,
1084
+        "lines_sum": sum_in_ticket_unit,
1085
+        "lines_delta": delta,
1086
+        "has_unconvertible": has_unconvertible,
1087
+        "allow_adjust_net": ticket.pickup.organization.allow_adjust_net_to_lines,
1088
+        "allow_residual": ticket.pickup.organization.allow_add_residual_line,
1089
+        "is_finalized": ticket.is_finalized,
752 1090
     }
753 1091
     return render(request, "recycle_core/weigh_ticket_detail.html", context)
754 1092
 
@@ -763,7 +1101,19 @@ def weigh_ticket_generate_invoice(request, pk: int):
763 1101
         messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
764 1102
     except Exception as e:
765 1103
         messages.error(request, f"Failed to generate invoice: {e}")
766
-    return redirect("recycle_core:weigh_tickets_list")
1104
+    return redirect("recycle_core:pickup_detail", pk=ticket.pickup_id)
1105
+
1106
+
1107
+@staff_member_required
1108
+@require_POST
1109
+def weigh_ticket_recalc_carbon(request, pk: int):
1110
+    ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__organization"), pk=pk)
1111
+    try:
1112
+        events = carbon_svc.calculate_events_for_ticket(ticket)
1113
+        messages.success(request, f"Recalculated carbon events: {len(events)} event(s).")
1114
+    except Exception as e:
1115
+        messages.error(request, f"Failed to recalculate carbon events: {e}")
1116
+    return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
767 1117
 
768 1118
 
769 1119
 # Billing-related views moved to billing/views.py
@@ -927,6 +1277,173 @@ def audit_logs_export_csv(request):
927 1277
             log.metadata,
928 1278
         ])
929 1279
     return response
1280
+
1281
+
1282
+# Carbon approvals queue -----------------------------------------------------
1283
+
1284
+
1285
+@staff_member_required
1286
+@public_route(label="Carbon Approvals", order=65, icon="check-circle")
1287
+@breadcrumbs(label="Carbon Events", name="re_carbon_events")
1288
+def carbon_events_list(request):
1289
+    class CarbonFilter(filters.FilterSet):
1290
+        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
1291
+        status = filters.ChoiceFilter(field_name="status", choices=CarbonEvent.STATUS_CHOICES)
1292
+        event_date = filters.DateFromToRangeFilter(field_name="event_date", label="Event between")
1293
+
1294
+        class Meta:
1295
+            model = CarbonEvent
1296
+            fields = ["organization", "status", "event_date"]
1297
+
1298
+    base_qs = CarbonEvent.objects.select_related("organization", "weigh_ticket", "material").order_by("-event_date")
1299
+    c_filter = CarbonFilter(request.GET, queryset=base_qs)
1300
+    qs = c_filter.qs
1301
+    org = getattr(request, "org", None)
1302
+    if org is not None:
1303
+        qs = qs.filter(organization=org)
1304
+
1305
+    if request.method == "POST":
1306
+        action = request.POST.get("action")
1307
+        ids = request.POST.getlist("ids")
1308
+        sel = list(qs.filter(id__in=ids))
1309
+        if action == "approve":
1310
+            n_total = 0
1311
+            for ev in sel:
1312
+                n_total += carbon_svc.approve_events(ev.organization, [ev], approver=request.user)
1313
+            messages.success(request, f"Approved {n_total} event(s).")
1314
+            return redirect("recycle_core:carbon_events_list")
1315
+        elif action == "reject":
1316
+            from django.utils import timezone
1317
+            now = timezone.now()
1318
+            for ev in sel:
1319
+                ev.status = CarbonEvent.STATUS_REJECTED
1320
+                ev.approved_by = request.user
1321
+                ev.approved_at = now
1322
+                ev.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
1323
+            messages.success(request, f"Rejected {len(sel)} event(s).")
1324
+            return redirect("recycle_core:carbon_events_list")
1325
+        else:
1326
+            messages.error(request, "Invalid action.")
1327
+
1328
+    paginator = Paginator(qs, 10)
1329
+    page = request.GET.get("page")
1330
+    try:
1331
+        page_obj = paginator.page(page)
1332
+    except PageNotAnInteger:
1333
+        page_obj = paginator.page(1)
1334
+    except EmptyPage:
1335
+        page_obj = paginator.page(paginator.num_pages)
1336
+
1337
+    context = {
1338
+        "nav": _nav_items(),
1339
+        "events": page_obj.object_list,
1340
+        "page_obj": page_obj,
1341
+        "filter": c_filter,
1342
+    }
1343
+    return render(request, "recycle_core/carbon_events_list.html", context)
1344
+
1345
+
1346
+# Emission Factors -----------------------------------------------------------
1347
+
1348
+
1349
+@staff_member_required
1350
+@public_route(label="Emission Factors", order=62, icon="bolt")
1351
+@breadcrumbs(label="Emission Factors", name="re_factors")
1352
+def emission_factors_list(request):
1353
+    class FactorFilter(filters.FilterSet):
1354
+        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
1355
+        material = filters.ModelChoiceFilter(queryset=Material.objects.all())
1356
+        unit = filters.ChoiceFilter(choices=Material.UNIT_CHOICES)
1357
+        effective_from = filters.DateFromToRangeFilter(field_name="effective_from", label="Effective from")
1358
+
1359
+        class Meta:
1360
+            model = MaterialEmissionFactor
1361
+            fields = ["organization", "material", "unit", "effective_from"]
1362
+
1363
+    form = MaterialEmissionFactorForm(request.POST or None)
1364
+    # Scope org choices
1365
+    if getattr(request, "org", None) is not None:
1366
+        try:
1367
+            form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
1368
+            form.fields["material"].queryset = Material.objects.filter(organization=request.org)
1369
+        except Exception:
1370
+            pass
1371
+    if request.method == "POST":
1372
+        if form.is_valid():
1373
+            obj = form.save(commit=False)
1374
+            if getattr(request, "org", None) is not None:
1375
+                obj.organization = request.org
1376
+            obj.save()
1377
+            messages.success(request, "Emission factor created.")
1378
+            return redirect("recycle_core:emission_factors_list")
1379
+        else:
1380
+            messages.error(request, "Please correct the errors below.")
1381
+
1382
+    base_qs = MaterialEmissionFactor.objects.select_related("organization", "material").order_by("organization_id", "material_id", "-effective_from", "-id")
1383
+    f_filter = FactorFilter(request.GET, queryset=base_qs)
1384
+    qs = f_filter.qs
1385
+    if getattr(request, "org", None) is not None:
1386
+        qs = qs.filter(organization=request.org)
1387
+
1388
+    paginator = Paginator(qs, 15)
1389
+    page = request.GET.get("page")
1390
+    try:
1391
+        page_obj = paginator.page(page)
1392
+    except PageNotAnInteger:
1393
+        page_obj = paginator.page(1)
1394
+    except EmptyPage:
1395
+        page_obj = paginator.page(paginator.num_pages)
1396
+
1397
+    context = {
1398
+        "nav": _nav_items(),
1399
+        "factors": page_obj.object_list,
1400
+        "page_obj": page_obj,
1401
+        "filter": f_filter,
1402
+        "form": form,
1403
+    }
1404
+    return render(request, "recycle_core/emission_factors_list.html", context)
1405
+
1406
+
1407
+@staff_member_required
1408
+@breadcrumbs(label="Edit Factor", parent="re_factors")
1409
+def emission_factor_edit(request, pk: int):
1410
+    item = get_object_or_404(MaterialEmissionFactor.objects.select_related("organization", "material"), pk=pk)
1411
+    if request.method == "POST":
1412
+        form = MaterialEmissionFactorForm(request.POST, instance=item)
1413
+        # Scope choices
1414
+        if getattr(request, "org", None) is not None:
1415
+            try:
1416
+                form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
1417
+                form.fields["material"].queryset = Material.objects.filter(organization=request.org)
1418
+            except Exception:
1419
+                pass
1420
+        if form.is_valid():
1421
+            inst = form.save(commit=False)
1422
+            if getattr(request, "org", None) is not None:
1423
+                inst.organization = request.org
1424
+            inst.save()
1425
+            messages.success(request, "Emission factor updated.")
1426
+            return redirect("recycle_core:emission_factors_list")
1427
+        else:
1428
+            messages.error(request, "Please correct the errors below.")
1429
+    else:
1430
+        form = MaterialEmissionFactorForm(instance=item)
1431
+        if getattr(request, "org", None) is not None:
1432
+            try:
1433
+                form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
1434
+                form.fields["material"].queryset = Material.objects.filter(organization=request.org)
1435
+            except Exception:
1436
+                pass
1437
+    return render(request, "recycle_core/emission_factor_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:emission_factors_list")})
1438
+
1439
+
1440
+@staff_member_required
1441
+@require_POST
1442
+def emission_factor_delete(request, pk: int):
1443
+    item = get_object_or_404(MaterialEmissionFactor, pk=pk)
1444
+    item.delete()
1445
+    messages.success(request, "Emission factor deleted.")
1446
+    return redirect("recycle_core:emission_factors_list")
930 1447
 @staff_member_required
931 1448
 @public_route(label="Provided Services", order=15, icon="sparkles")
932 1449
 @breadcrumbs(label="Provided Services", name="rc_services")

golf/wellness - Gogs: Simplico Git Service

1 Commity (e0cfb927989b04ef3c19849874e247513d9c5028)

Autor SHA1 Wiadomość Data
  inboxz 0494d5436c update-260821 4 lat temu