m lines-num-old"> 25
-      <div>
26
-        <h2 class="text-base font-semibold mb-2">{% trans "Profile Photo" %}</h2>
27
-        <div class="flex items-start gap-4">
28
-          <div>
29
-            <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
30
-            {% if not request.user.recycle_profile.my_photo %}
31
-              <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
34
+        {% endif %}
35
+        <form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4">
36
+          {% csrf_token %}
37
+          <div class="grid grid-cols-1 gap-6">
38
+            <div>
39
+              <h2 class="text-base font-semibold mb-2">{% trans "Account" %}</h2>
40
+              <div class="grid md:grid-cols-2 gap-4 mb-2">
41
+                <div>
42
+                  <label class="block text-sm font-medium mb-1">{% trans "Username" %}</label>
43
+                  <input type="text" value="{{ username_value }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
44
+                </div>
45
+                <div>
46
+                  <label class="block text-sm font-medium mb-1">{% trans "Role" %}</label>
47
+                  <input type="text" value="{{ role_label }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
48
+                </div>
49
+              </div>
50
+              {{ form_user|crispy }}
51
+            </div>
52
+            {% if has_profile %}
53
+            <div>
54
+              <h2 class="text-base font-semibold mb-2">{% trans "Profile Photo" %}</h2>
55
+              <div class="flex items-start gap-4">
56
+                <div>
57
+                  <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
58
+                  {% if not request.user.recycle_profile.my_photo %}
59
+                    <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
60
+                  {% endif %}
61
+                </div>
62
+                <div class="flex-1">
63
+                  {{ form_photo.my_photo }}
64
+                  <div class="flex items-center gap-3">
65
+                    <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
66
+                    {% if form_photo.remove_photo %}
67
+                      <label class="inline-flex items-center gap-2 text-sm text-gray-700">
68
+                        {{ form_photo.remove_photo }} Remove current photo
69
+                      </label>
70
+                    {% endif %}
71
+                  </div>
72
+                  <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
73
+                  <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
74
+                </div>
75
+              </div>
76
+            </div>
77
+            <div>
78
+              <h2 class="text-base font-semibold mb-2">{% trans "Contact & Address" %}</h2>
79
+              {{ form_extras|crispy }}
80
+            </div>
32 81
             {% endif %}
33 82
           </div>
34
-          <div class="flex-1">
35
-            {{ form_photo.my_photo }}
36
-            <div class="flex items-center gap-3">
37
-              <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
38
-              {% if form_photo.remove_photo %}
39
-                <label class="inline-flex items-center gap-2 text-sm text-gray-700">
40
-                  {{ form_photo.remove_photo }} Remove current photo
41
-                </label>
42
-              {% endif %}
43
-            </div>
44
-            <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
45
-            <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
83
+          <div class="mt-4">
84
+            <button class="btn-primary" type="submit">{% trans "Save Changes" %}</button>
85
+            <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
86
+          </div>
87
+        </form>
88
+      </div>
89
+      <div x-show="section==='history'" x-cloak>
90
+        <div class="bg-white rounded shadow p-4">
91
+          <h2 class="text-base font-semibold mb-3">{% trans "Request History" %}</h2>
92
+          {% if request_history %}
93
+          <div class="overflow-x-auto">
94
+            <table class="min-w-full text-sm">
95
+              <thead>
96
+                <tr class="text-left text-gray-600">
97
+                  <th class="px-3 py-2">{% trans "Date" %}</th>
98
+                  <th class="px-3 py-2">{% trans "Subject" %}</th>
99
+                  <th class="px-3 py-2">{% trans "Organization" %}</th>
100
+                </tr>
101
+              </thead>
102
+              <tbody class="bg-white">
103
+                {% for it in request_history %}
104
+                <tr class="border-t">
105
+                  <td class="px-3 py-2 whitespace-nowrap">{{ it.created_at|date:"Y-m-d H:i" }}</td>
106
+                  <td class="px-3 py-2">{{ it.subject|default:"Pickup Request" }}</td>
107
+                  <td class="px-3 py-2">{{ it.organization.name }}</td>
108
+                </tr>
109
+                {% endfor %}
110
+              </tbody>
111
+            </table>
46 112
           </div>
113
+          {% else %}
114
+            <div class="text-sm text-gray-600">{% trans "No requests found for your account email." %}</div>
115
+          {% endif %}
47 116
         </div>
48 117
       </div>
49
-      {% endif %}
50
-    </div>
51
-    <div class="mt-4">
52
-      <button class="btn-primary" type="submit">{% trans "Save Changes" %}</button>
53
-      <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
54
-    </div>
55
-  </form>
118
+    </section>
119
+  </div>
56 120
 </div>
57 121
 <script>
58 122
   (function(){

+ 62 - 4
public_frontend/views.py

@@ -10,7 +10,14 @@ from recycle_core.models import ProvidedService
10 10
 from recycle_core.models import Material, ScrapListing, PickupOrder
11 11
 from cms.models import Post, PostCategory
12 12
 
13
-from .forms import PickupRequestForm, ContactForm, RegistrationForm, PublicUserForm, PublicUserPhotoForm
13
+from .forms import (
14
+    PickupRequestForm,
15
+    ContactForm,
16
+    RegistrationForm,
17
+    PublicUserForm,
18
+    PublicUserPhotoForm,
19
+    PublicUserProfileExtraForm,
20
+)
14 21
 from .models import Lead
15 22
 from recycle_core.controllers.pickup_request import (
16 23
     PickupRequestController,
@@ -107,7 +114,15 @@ def service_detail(request, pk: int):
107 114
 
108 115
 def pickup_request(request):
109 116
     org = getattr(request, "org", None)
110
-    form = PickupRequestForm(request.POST or None, request.FILES or None)
117
+    # Prefill form for authenticated users
118
+    initial = {}
119
+    if request.user.is_authenticated:
120
+        u = request.user
121
+        full_name = (getattr(u, "get_full_name", lambda: "")() or "").strip()
122
+        initial["name"] = full_name or u.username
123
+        if getattr(u, "email", ""):
124
+            initial["email"] = u.email
125
+    form = PickupRequestForm(request.POST or None, request.FILES or None, initial=initial)
111 126
     if request.method == "POST":
112 127
         if not org:
113 128
             messages.error(request, "Organization context missing.")
@@ -123,6 +138,7 @@ def pickup_request(request):
123 138
                 materials=form.cleaned_data.get("materials", ""),
124 139
                 preferred_at=form.cleaned_data.get("preferred_at"),
125 140
                 files=request.FILES.getlist("photos") if hasattr(request, "FILES") and "photos" in request.FILES else [],
141
+                created_by=request.user if request.user.is_authenticated else None,
126 142
             )
127 143
             result = ctrl.submit(data)
128 144
             if result.ok:
@@ -151,6 +167,7 @@ def contact(request):
151 167
                 subject=form.cleaned_data.get("subject", ""),
152 168
                 message=form.cleaned_data.get("message", ""),
153 169
                 source="contact",
170
+                created_by=request.user if request.user.is_authenticated else None,
154 171
             )
155 172
             messages.success(request, "Thanks! We will get back to you shortly.")
156 173
             return redirect("public_frontend:home")
@@ -230,6 +247,8 @@ class PublicLoginView(LoginView):
230 247
 
231 248
 from django.contrib.auth.decorators import login_required
232 249
 from django.contrib.auth import get_user_model
250
+from django.utils import timezone
251
+from recycle_core.models import CarbonEvent, CarbonBalance
233 252
 
234 253
 
235 254
 @login_required
@@ -241,21 +260,60 @@ def my_profile(request):
241 260
     if request.method == "POST":
242 261
         form_user = PublicUserForm(request.POST, instance=user)
243 262
         form_photo = PublicUserPhotoForm(request.POST, request.FILES, instance=profile) if profile else None
263
+        form_extras = PublicUserProfileExtraForm(request.POST, instance=profile) if profile else None
244 264
         ok_user = form_user.is_valid()
245 265
         ok_photo = True if form_photo is None else form_photo.is_valid()
246
-        if ok_user and ok_photo:
266
+        ok_extras = True if form_extras is None else form_extras.is_valid()
267
+        if ok_user and ok_photo and ok_extras:
247 268
             form_user.save()
248 269
             if form_photo is not None:
249 270
                 form_photo.save()
271
+            if form_extras is not None:
272
+                form_extras.save()
250 273
             messages.success(request, "Profile updated.")
251 274
             return redirect(reverse("public_frontend:my_profile"))
252 275
         messages.error(request, "Please correct the errors below.")
253 276
     else:
254 277
         form_user = PublicUserForm(instance=user)
255 278
         form_photo = PublicUserPhotoForm(instance=profile) if profile else None
279
+        form_extras = PublicUserProfileExtraForm(instance=profile) if profile else None
280
+
281
+    # Request history (pickup requests submitted via public form, matched by email)
282
+    history = []
283
+    try:
284
+        email = (user.email or "").strip()
285
+        if email:
286
+            qs = Lead.objects.filter(email__iexact=email, source="pickup_request").order_by("-created_at")
287
+            org = getattr(request, "org", None)
288
+            if org is not None:
289
+                qs = qs.filter(organization=org)
290
+            history = list(qs[:20])
291
+    except Exception:
292
+        history = []
293
+
294
+    # Carbon summary for user's organization
295
+    org = getattr(request, "org", None) or getattr(profile, "organization", None)
296
+    carbon = None
297
+    if org is not None:
298
+        today = timezone.now().date()
299
+        bal = CarbonBalance.objects.filter(organization=org, year=today.year, month=today.month).first()
300
+        pending = CarbonEvent.objects.filter(organization=org, status=CarbonEvent.STATUS_PENDING).count()
301
+        carbon = {
302
+            "pending_events": pending,
303
+            "approved_mtd": bal.approved_kgco2e if bal else 0,
304
+        }
256 305
 
257 306
     return render(
258 307
         request,
259 308
         "public_frontend/my_profile.html",
260
-        {"form_user": form_user, "form_photo": form_photo, "has_profile": bool(profile), "username_value": user.username, "role_label": role_label},
309
+        {
310
+            "form_user": form_user,
311
+            "form_photo": form_photo,
312
+            "form_extras": form_extras,
313
+            "has_profile": bool(profile),
314
+            "username_value": user.username,
315
+            "role_label": role_label,
316
+            "request_history": history,
317
+            "carbon": carbon,
318
+        },
261 319
     )

+ 7 - 0
recycle_core/apps.py

@@ -4,3 +4,10 @@ from django.apps import AppConfig
4 4
 class RecycleCoreConfig(AppConfig):
5 5
     default_auto_field = 'django.db.models.BigAutoField'
6 6
     name = 'recycle_core'
7
+
8
+    def ready(self):  # pragma: no cover - side effects only
9
+        try:
10
+            from . import signals  # noqa: F401
11
+        except Exception:
12
+            # Avoid crashing app loading if migrations incomplete
13
+            pass

+ 3 - 1
recycle_core/controllers/pickup_request.py

@@ -2,6 +2,7 @@ from __future__ import annotations
2 2
 
3 3
 from dataclasses import dataclass
4 4
 from typing import Iterable, List, Optional
5
+from django.contrib.auth import get_user_model
5 6
 from django.contrib.contenttypes.models import ContentType
6 7
 from django.utils import timezone
7 8
 
@@ -19,6 +20,7 @@ class PickupRequestData:
19 20
     materials: str = ""
20 21
     preferred_at: Optional[timezone.datetime] = None
21 22
     files: Optional[Iterable] = None  # iterable of UploadedFile
23
+    created_by: Optional[object] = None  # User, but avoid circular import typing
22 24
 
23 25
 
24 26
 @dataclass
@@ -56,6 +58,7 @@ class PickupRequestController:
56 58
                 subject="Pickup Request",
57 59
                 message=message,
58 60
                 source="pickup_request",
61
+                created_by=data.created_by if getattr(data, "created_by", None) else None,
59 62
             )
60 63
 
61 64
             # Attach uploaded files as Documents linked to the Lead
@@ -77,4 +80,3 @@ class PickupRequestController:
77 80
 
78 81
         except Exception as e:
79 82
             return PickupRequestResult(ok=False, error=str(e), document_ids=[])
80
-

+ 68 - 0
recycle_core/forms.py

@@ -12,6 +12,8 @@ from .models import (
12 12
     ProvidedService,
13 13
     Customer,
14 14
     CustomerSite,
15
+    MaterialEmissionFactor,
16
+    PickupItem,
15 17
 )
16 18
 from markdownfield.widgets import MDEWidget
17 19
 from orgs.models import UserProfile
@@ -251,3 +253,69 @@ class WeighTicketForm(forms.Form):
251 253
         if gross is not None and tare is not None and net is None:
252 254
             cleaned["net_weight"] = (Decimal(gross) - Decimal(tare)).quantize(Decimal("0.001"))
253 255
         return cleaned
256
+
257
+
258
+class WeighLineForm(forms.Form):
259
+    material = forms.ModelChoiceField(queryset=Material.objects.none())
260
+    quantity = forms.DecimalField(max_digits=12, decimal_places=3)
261
+    unit = forms.ChoiceField(choices=(), required=True)
262
+
263
+    def __init__(self, *args, **kwargs):
264
+        org = kwargs.pop("organization", None)
265
+        super().__init__(*args, **kwargs)
266
+        from .models import Material as Mat
267
+        self.fields["unit"].choices = Mat.UNIT_CHOICES
268
+        # Scope materials by organization if provided
269
+        if org is not None:
270
+            self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name")
271
+        else:
272
+            self.fields["material"].queryset = Material.objects.all().order_by("name")
273
+
274
+
275
+class MaterialEmissionFactorForm(forms.ModelForm):
276
+    class Meta:
277
+        model = MaterialEmissionFactor
278
+        fields = [
279
+            "organization",
280
+            "material",
281
+            "unit",
282
+            "kgco2e_per_unit",
283
+            "source",
284
+            "effective_from",
285
+            "effective_to",
286
+        ]
287
+
288
+
289
+class PickupItemForm(forms.Form):
290
+    material = forms.ModelChoiceField(queryset=Material.objects.none())
291
+    estimated_qty = forms.DecimalField(max_digits=12, decimal_places=3, required=False)
292
+    unit = forms.ChoiceField(choices=(), required=True)
293
+
294
+    def __init__(self, *args, **kwargs):
295
+        org = kwargs.pop("organization", None)
296
+        super().__init__(*args, **kwargs)
297
+        from .models import Material as Mat
298
+        self.fields["unit"].choices = Mat.UNIT_CHOICES
299
+        if org is not None:
300
+            self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name")
301
+        else:
302
+            self.fields["material"].queryset = Material.objects.all().order_by("name")
303
+
304
+
305
+class WeighTicketWeightsForm(forms.Form):
306
+    gross_weight = forms.DecimalField(max_digits=12, decimal_places=3)
307
+    tare_weight = forms.DecimalField(max_digits=12, decimal_places=3)
308
+    unit = forms.ChoiceField(choices=(), required=True)
309
+
310
+    def __init__(self, *args, **kwargs):
311
+        super().__init__(*args, **kwargs)
312
+        from .models import Material as Mat
313
+        self.fields["unit"].choices = Mat.UNIT_CHOICES
314
+
315
+    def clean(self):
316
+        cleaned = super().clean()
317
+        g = cleaned.get("gross_weight")
318
+        t = cleaned.get("tare_weight")
319
+        if g is not None and t is not None and g < t:
320
+            self.add_error("gross_weight", "Gross must be greater than or equal to tare")
321
+        return cleaned

+ 11 - 0
recycle_core/management/commands/seed_ecoloop.py

@@ -25,6 +25,7 @@ from recycle_core.models import (
25 25
     ScrapBid,
26 26
     ScrapListingInvite,
27 27
     ScrapAward,
28
+    MaterialEmissionFactor,
28 29
 )
29 30
 from recycle_core.services.billing import generate_invoice_for_pickup
30 31
 
@@ -131,6 +132,16 @@ class Command(BaseCommand):
131 132
         can, _ = Material.objects.get_or_create(organization=org, category="Metals", name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
132 133
         cardboard, _ = Material.objects.get_or_create(organization=org, category="Paper", name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
133 134
 
135
+        # Emission factors (demo values)
136
+        MaterialEmissionFactor.objects.get_or_create(
137
+            organization=org, material=pet, unit=Material.UNIT_KG,
138
+            defaults={"kgco2e_per_unit": Decimal("0.050"), "source": "Demo factor"}
139
+        )
140
+        MaterialEmissionFactor.objects.get_or_create(
141
+            organization=org, material=can, unit=Material.UNIT_KG,
142
+            defaults={"kgco2e_per_unit": Decimal("0.150"), "source": "Demo factor"}
143
+        )
144
+
134 145
         # Price list
135 146
         pl, _ = PriceList.objects.get_or_create(
136 147
             organization=org,

+ 77 - 0
recycle_core/migrations/0003_materialemissionfactor_carbonevent_carbonbalance.py

@@ -0,0 +1,77 @@
1
+# Generated by Django 4.2.24 on 2025-09-30 08:47
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+import django.utils.timezone
7
+
8
+
9
+class Migration(migrations.Migration):
10
+
11
+    dependencies = [
12
+        ('orgs', '0008_userprofile_address_line1_userprofile_address_line2_and_more'),
13
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+        ('recycle_core', '0002_alter_pickuporder_options_alter_scraplisting_options_and_more'),
15
+    ]
16
+
17
+    operations = [
18
+        migrations.CreateModel(
19
+            name='MaterialEmissionFactor',
20
+            fields=[
21
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+                ('created_at', models.DateTimeField(auto_now_add=True)),
23
+                ('updated_at', models.DateTimeField(auto_now=True)),
24
+                ('unit', models.CharField(choices=[('kg', 'Kilogram'), ('lb', 'Pound'), ('pcs', 'Pieces')], default='kg', max_length=8)),
25
+                ('kgco2e_per_unit', models.DecimalField(decimal_places=6, max_digits=16)),
26
+                ('source', models.CharField(blank=True, max_length=255)),
27
+                ('effective_from', models.DateField(blank=True, null=True)),
28
+                ('effective_to', models.DateField(blank=True, null=True)),
29
+                ('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emission_factors', to='recycle_core.material')),
30
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='material_factors', to='orgs.organization')),
31
+            ],
32
+            options={
33
+                'indexes': [models.Index(fields=['organization', 'material', 'unit'], name='recycle_cor_organiz_71d624_idx')],
34
+            },
35
+        ),
36
+        migrations.CreateModel(
37
+            name='CarbonEvent',
38
+            fields=[
39
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
40
+                ('created_at', models.DateTimeField(auto_now_add=True)),
41
+                ('updated_at', models.DateTimeField(auto_now=True)),
42
+                ('quantity', models.DecimalField(decimal_places=3, max_digits=14)),
43
+                ('unit', models.CharField(choices=[('kg', 'Kilogram'), ('lb', 'Pound'), ('pcs', 'Pieces')], default='kg', max_length=8)),
44
+                ('kgco2e', models.DecimalField(decimal_places=6, max_digits=18)),
45
+                ('event_date', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
46
+                ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)),
47
+                ('approved_at', models.DateTimeField(blank=True, null=True)),
48
+                ('notes', models.TextField(blank=True)),
49
+                ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_carbon_events', to=settings.AUTH_USER_MODEL)),
50
+                ('material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recycle_core.material')),
51
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carbon_events', to='orgs.organization')),
52
+                ('weigh_line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carbon_events', to='recycle_core.weighline')),
53
+                ('weigh_ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carbon_events', to='recycle_core.weighticket')),
54
+            ],
55
+            options={
56
+                'indexes': [models.Index(fields=['organization', 'status'], name='recycle_cor_organiz_fad901_idx'), models.Index(fields=['organization', 'event_date'], name='recycle_cor_organiz_3206eb_idx')],
57
+                'unique_together': {('weigh_line',)},
58
+            },
59
+        ),
60
+        migrations.CreateModel(
61
+            name='CarbonBalance',
62
+            fields=[
63
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
64
+                ('created_at', models.DateTimeField(auto_now_add=True)),
65
+                ('updated_at', models.DateTimeField(auto_now=True)),
66
+                ('year', models.PositiveSmallIntegerField()),
67
+                ('month', models.PositiveSmallIntegerField()),
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")

tmt/tiger_frontend - Gogs: Simplico Git Service

Açıklama Yok

golf 77628cf8bf first comm 3 yıl önce
..
example 77628cf8bf first comm 3 yıl önce
test 77628cf8bf first comm 3 yıl önce
.travis.yml 77628cf8bf first comm 3 yıl önce
LICENSE 77628cf8bf first comm 3 yıl önce
README.markdown 77628cf8bf first comm 3 yıl önce
index.js 77628cf8bf first comm 3 yıl önce
package.json 77628cf8bf first comm 3 yıl önce

README.markdown

concat-map

Concatenative mapdashery.

browser support

build status

example

var concatMap = require('concat-map');
var xs = [ 1, 2, 3, 4, 5, 6 ];
var ys = concatMap(xs, function (x) {
    return x % 2 ? [ x - 0.1, x, x + 0.1 ] : [];
});
console.dir(ys);

[ 0.9, 1, 1.1, 2.9, 3, 3.1, 4.9, 5, 5.1 ]

methods

var concatMap = require('concat-map')

concatMap(xs, fn)

Return an array of concatenated elements by calling fn(x, i) for each element x and each index i in the array xs.

When fn(x, i) returns an array, its result will be concatenated with the result array. If fn(x, i) returns anything else, that value will be pushed onto the end of the result array.

install

With npm do:

npm install concat-map

license

MIT

notes

This module was written while sitting high above the ground in a tree.