">mpty %} 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")

tum/whitesports - Gogs: Simplico Git Service

1 コミット (master)

作者 SHA1 メッセージ 日付
  Tum d01d7cf85d first commit 4 年 前